diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a439fb1..33a39d22 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,6 +19,15 @@ jobs: secrets: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + # Run Mend CLI Scan + mend-cli-scan: + name: Mend CLI Scan + uses: ./.github/workflows/mend-cli-scan.yaml + secrets: + MEND_EMAIL: ${{ secrets.MEND_EMAIL }} + MEND_USER_KEY: ${{ secrets.MEND_USER_KEY }} + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + # Run Sonatype OSS Index Scan sonatype-ossindex: name: Scan for open source vulnerabilities (Sonatype OSS Index) @@ -72,7 +81,7 @@ jobs: name: Publish SNAPSHOT release uses: ./.github/workflows/publish-snapshot.yaml if: (github.ref == 'refs/heads/develop' && github.event_name == 'push') - needs: [bitbar-results, sonatype-ossindex] + needs: [bitbar-results, sonatype-ossindex, mend-cli-scan] secrets: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} PUBLISHING_SIGNING_KEY_ID: ${{ secrets.PUBLISHING_SIGNING_KEY_ID }} diff --git a/.github/workflows/mend-cli-scan.yaml b/.github/workflows/mend-cli-scan.yaml new file mode 100644 index 00000000..775478c1 --- /dev/null +++ b/.github/workflows/mend-cli-scan.yaml @@ -0,0 +1,107 @@ +name: Run Mend CLS Scan +on: + workflow_call: + secrets: + MEND_EMAIL: + description: Mend email + required: true + MEND_USER_KEY: + description: Mend user key + required: true + SLACK_WEBHOOK: + description: Slack Notifier Incoming Webhook + required: true + +jobs: + mend-cli-scan: + runs-on: ubuntu-latest + + steps: + # Clone the repo + - name: Clone the repository + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{github.event.pull_request.head.repo.full_name}} + fetch-depth: 0 + + # Setup JDK and cache and restore dependencies. + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + # Setup Mend CLI + - name: Download and cache the Mend CLI executable + id: cache-mend + uses: actions/cache@v3 + env: + mend-cache-name: cache-mend-executable + with: + path: /usr/local/bin/mend + key: ${{ runner.os }}-${{ env.mend-cache-name }}-${{ hashFiles('/usr/local/bin/mend') }} + restore-keys: | + ${{ runner.os }}-${{ env.mend-cache-name }}- + + # Download Mend CLI if it's not cached... + - if: ${{ steps.cache-mend.outputs.cache-hit != 'true' }} + name: Download Mend CLI executable (cache miss...) + continue-on-error: true + run: | + echo "Download Mend CLI executable (cache miss...)" + curl https://downloads.mend.io/cli/linux_amd64/mend -o /usr/local/bin/mend && chmod +x /usr/local/bin/mend + + # Execute the Mend CLI scan + - name: Mend CLI Scan + env: + MEND_EMAIL: ${{secrets.MEND_EMAIL}} + MEND_USER_KEY: ${{secrets.MEND_USER_KEY}} + MEND_URL: ${{ vars.MEND_SERVER_URL }} + run: | + mend dep --no-color -s ${{ vars.MEND_PRODUCT_NAME }}//${{ vars.MEND_PROJECT_NAME }} -u > mend-scan-result.txt + echo "MEND_SCAN_URL=$(cat mend-scan-result.txt | grep -Eo '(http|https)://[a-zA-Z0-9./?!=_%:-\#]*')" >> $GITHUB_ENV + echo "MEND_SCAN_SUMMARY=$(cat mend-scan-result.txt | grep -Eoiw '(Detected [0-9]* vulnerabilities.*)')" >> $GITHUB_ENV + echo "MEND_CRITICAL_COUNT=$(cat mend-scan-result.txt | grep -Eoiw '(Detected [0-9]* vulnerabilities.*)' | grep -oi '[0-9]* Critical' | grep -o [0-9]*)" >> $GITHUB_ENV + echo "MEND_HIGH_COUNT=$(cat mend-scan-result.txt | grep -Eoiw '(Detected [0-9]* vulnerabilities.*)' | grep -oi '[0-9]* High' | grep -o [0-9]*)" >> $GITHUB_ENV + + # Check for failures and set the outcome of the workflow + - name: Parse the result and set job status + if: always() + run: | + if [ '${{ env.MEND_CRITICAL_COUNT }}' -gt '0' ] || [ '${{ env.MEND_HIGH_COUNT }}' -gt '0' ]; then + exit 1 + else + exit 0 + fi + + # Publish the result + - name: Mend Scan Result + uses: LouisBrunner/checks-action@v1.6.1 + if: always() + with: + name: "Mend Scan Result" + token: ${{ secrets.GITHUB_TOKEN }} + conclusion: ${{ job.status }} + output_text_description_file: mend-scan-result.txt + output: | + {"title":"Mend Scan Result", "summary":"${{ job.status }}"} + + # Send slack notification with result status + - name: Send slack notification + uses: 8398a7/action-slack@v3 + with: + status: custom + fields: all + custom_payload: | + { + attachments: [{ + title: 'ForgeRock Android SDK Mend Scan', + color: '${{ job.status }}' === 'success' ? 'good' : '${{ job.status }}' === 'failure' ? 'danger' : 'warning', + text: `\nStatus: ${{ job.status }}\nWorkflow: ${process.env.AS_WORKFLOW} -> ${process.env.AS_JOB}\nSummary: ${{ env.MEND_SCAN_SUMMARY }}\nScan URL: ${{ env.MEND_SCAN_URL }}`, + }] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + if: always() \ No newline at end of file diff --git a/.whitesource b/.whitesource deleted file mode 100644 index fdcaa0bb..00000000 --- a/.whitesource +++ /dev/null @@ -1,23 +0,0 @@ -{ - "scanSettings": { - "configMode": "AUTO", - "configExternalURL": "", - "projectToken": "", - "baseBranches": ["develop"], - "enableLicenseViolations": true - }, - "checkRunSettings": { - "vulnerableCheckRunConclusionLevel": "failure", - "displayMode": "diff", - "useMendCheckNames": true - }, - "issueSettings": { - "minSeverityLevel": "LOW", - "issueType": "DEPENDENCY" - }, - "remediateSettings": { - "workflowRules": { - "enabled": true - } - } -} diff --git a/CHANGELOG.md b/CHANGELOG.md index c39f3107..3f8e30fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [4.3.0] +#### Added +- Added the ability to customize cookie headers in outgoing requests from the SDK [SDKS-2780] +- Added the ability to insert custom claims when performing device signing verification [SDKS-2787] +- Added client-side support for the `AppIntegrity` callback [SDKS-2631] + + +#### Fixed +- The SDK now uses `auth-per-use` keys for Device Binding [SDKS-2797] +- Improved handling of WebAuthn cancellations [SDKS-2819] +- Made `forgerock_url`, `forgerock_realm`, and `forgerock_cookie_name` params mandatory when dynamically configuring the SDK [SDKS-2782] +- Addressed `woodstox-core:6.2.4` library security vulnerability (CVE-2022-40152) [SDKS-2751] + ## [4.2.0] #### Added - Gradle 8 and JDK 17 support [SDKS-2451] diff --git a/build.gradle b/build.gradle index 6c908916..17517f38 100644 --- a/build.gradle +++ b/build.gradle @@ -26,8 +26,6 @@ buildscript { classpath "com.adarshr:gradle-test-logger-plugin:2.0.0" classpath 'com.google.gms:google-services:4.3.15' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.8.20" - // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } @@ -36,15 +34,41 @@ buildscript { plugins { id('io.github.gradle-nexus.publish-plugin') version '1.1.0' id('org.sonatype.gradle.plugins.scan') version '2.4.0' + id("org.jetbrains.dokka") version "1.9.10" } -apply plugin: "org.jetbrains.dokka" allprojects { + configurations.all { + + resolutionStrategy { + // Due to vulnerability [CVE-2022-40152] from dokka project. + force 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.5' + force 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.13.5' + force 'com.fasterxml.jackson.core:jackson-databind:2.13.5' + // Junit test project + force 'junit:junit:4.13.2' + //Due to Vulnerability [CVE-2022-2390]: CWE-471 The product does not properly + // protect an assumed-immutable element from being modified by an attacker. + // on version < 18.0.1, this library is depended by most of the google libraries. + // and needs to be reviewed on upgrades + force 'com.google.android.gms:play-services-basement:18.1.0' + //Due to Vulnerability [CVE-2023-3635] CWE-681: Incorrect Conversion between Numeric Types + //on version < 3.4.0, this library is depended by okhttp, when okhttp upgrade, this needs + //to be reviewed + force 'com.squareup.okio:okio:3.4.0' + //Due to this https://github.com/powermock/powermock/issues/1125, we have to keep using an + //older version of mockito until mockito release a fix + force 'org.mockito:mockito-core:3.12.4' + // this is for the mockwebserver + force 'org.bouncycastle:bcprov-jdk15on:1.68' + } + } repositories { google() mavenCentral() } + } subprojects { diff --git a/config/kdoc.gradle b/config/kdoc.gradle index b7048901..04f34b2e 100644 --- a/config/kdoc.gradle +++ b/config/kdoc.gradle @@ -55,6 +55,13 @@ dokkaJavadoc { } } +tasks.named("dokkaHtml").configure { + dependsOn("generateDebugRFile") + dependsOn("bundleLibCompileToJarDebug") + dependsOn("generateReleaseRFile") + dependsOn("bundleLibCompileToJarRelease") +} + dokkaHtml { dokkaSourceSets { named("main") { diff --git a/forgerock-auth-ui/build.gradle b/forgerock-auth-ui/build.gradle index 3d0b1286..7db87fc5 100644 --- a/forgerock-auth-ui/build.gradle +++ b/forgerock-auth-ui/build.gradle @@ -43,11 +43,6 @@ android { apply from: '../config/kdoc.gradle' apply from: '../config/publish.gradle' -configurations.all { - resolutionStrategy { - force 'com.google.android.gms:play-services-basement:18.1.0' - } -} dependencies { diff --git a/forgerock-auth-ui/src/main/java/org/forgerock/android/auth/ui/CallbackFragmentFactory.java b/forgerock-auth-ui/src/main/java/org/forgerock/android/auth/ui/CallbackFragmentFactory.java index 1d6602d3..b204c5d3 100644 --- a/forgerock-auth-ui/src/main/java/org/forgerock/android/auth/ui/CallbackFragmentFactory.java +++ b/forgerock-auth-ui/src/main/java/org/forgerock/android/auth/ui/CallbackFragmentFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 - 2022 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -58,6 +58,7 @@ private CallbackFragmentFactory() { register(SuspendedTextOutputCallback.class, SuspendedTextOutputCallbackFragment.class); register(ReCaptchaCallback.class, ReCaptchaCallbackFragment.class); register(ConsentMappingCallback.class, ConsentMappingCallbackFragment.class); + register(AppIntegrityCallback.class, AppIntegrityCallbackFragment.class); register(DeviceProfileCallback.class, DeviceProfileCallbackFragment.class); register(DeviceBindingCallback.class, DeviceBindingCallbackFragment.class); register(DeviceSigningVerifierCallback.class, DeviceSigningVerifierCallbackFragment.class); diff --git a/forgerock-auth-ui/src/main/java/org/forgerock/android/auth/ui/callback/AppIntegrityCallbackFragment.java b/forgerock-auth-ui/src/main/java/org/forgerock/android/auth/ui/callback/AppIntegrityCallbackFragment.java new file mode 100644 index 00000000..5188ee04 --- /dev/null +++ b/forgerock-auth-ui/src/main/java/org/forgerock/android/auth/ui/callback/AppIntegrityCallbackFragment.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.ui.callback; + + +import android.app.Activity; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.fragment.app.Fragment; + +import org.forgerock.android.auth.FRListener; +import org.forgerock.android.auth.Logger; +import org.forgerock.android.auth.callback.AppIntegrityCallback; +import org.forgerock.android.auth.ui.R; + +import static android.view.View.GONE; + +/** + * A simple {@link Fragment} subclass. + */ +public class AppIntegrityCallbackFragment extends CallbackFragment { + + private TextView message; + private ProgressBar progressBar; + + public AppIntegrityCallbackFragment() { + // Required empty public constructor + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + + + // Inflate the layout for this fragment + View view = inflater.inflate(R.layout.fragment_app_integrity_callback, container, false); + message = view.findViewById(R.id.message); + progressBar = view.findViewById(R.id.appIntegrityApiCallProgress); + + if (node.getCallbacks().size() == 1) { //auto submit if there is one node + progressBar.setVisibility(View.VISIBLE); + message.setText("Performing " + callback.getRequestType() + " call..."); + } else { + progressBar.setVisibility(GONE); + message.setVisibility(GONE); + } + + proceed(); + return view; + } + + private void proceed() { + final Activity thisActivity = (Activity) this.getActivity(); + callback.requestIntegrityToken(this.getContext(), new FRListener() { + @Override + public void onSuccess(Void result) { + thisActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + message.setVisibility(GONE); + progressBar.setVisibility(GONE); + if (node.getCallbacks().size() == 1) { //auto submit if there is one node + next(); + } + } + }); + } + + @Override + public void onException(Exception e) { + message.setVisibility(GONE); + progressBar.setVisibility(GONE); + Logger.error("AppIntegrityCallback", e.toString()); + cancel(e); + } + }); + } +} diff --git a/forgerock-auth-ui/src/main/res/layout/fragment_app_integrity_callback.xml b/forgerock-auth-ui/src/main/res/layout/fragment_app_integrity_callback.xml new file mode 100644 index 00000000..bba7340c --- /dev/null +++ b/forgerock-auth-ui/src/main/res/layout/fragment_app_integrity_callback.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/forgerock-auth/build.gradle b/forgerock-auth/build.gradle index 4f678fd7..d3753039 100644 --- a/forgerock-auth/build.gradle +++ b/forgerock-auth/build.gradle @@ -11,7 +11,6 @@ apply plugin: 'maven-publish' apply plugin: 'signing' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' -apply plugin: 'org.jetbrains.dokka' android { namespace 'org.forgerock.android.auth' @@ -70,6 +69,10 @@ android { viewBinding true } + kotlinOptions { + freeCompilerArgs = ['-Xjvm-default=all'] + } + } apply from: '../config/logger.gradle' @@ -79,17 +82,6 @@ apply from: '../config/publish.gradle' * Dependencies * */ -configurations.all { - resolutionStrategy { - force 'com.google.android.gms:play-services-basement:18.1.0' - force 'junit:junit:4.13.2' - force 'org.bouncycastle:bcprov-jdk15on:1.68' - //Due to Vulnerability [CVE-2023-3635] CWE-681: Incorrect Conversion between Numeric Types - //on version < 3.4.0, this library is depended by okhttp, when okhttp upgrade, this needs - //to be reviewed - force 'com.squareup.okio:okio:3.4.0' - } -} dependencies { api project(':forgerock-core') implementation fileTree(dir: 'libs', include: ['*.jar']) @@ -110,7 +102,7 @@ dependencies { compileOnly 'com.google.android.gms:play-services-location:21.0.1' compileOnly 'com.google.android.gms:play-services-safetynet:18.0.1' // Keeping this version for now, its breaking Apple SignIn for the later versions. - compileOnly 'net.openid:appauth:0.7.1' + compileOnly 'net.openid:appauth:0.11.1' compileOnly 'com.google.android.gms:play-services-fido:20.0.1' //For Device Binding @@ -124,11 +116,14 @@ dependencies { compileOnly 'com.google.android.gms:play-services-auth:20.6.0' compileOnly 'com.facebook.android:facebook-login:16.0.0' + //For App integrity + compileOnly 'com.google.android.play:integrity:1.3.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'com.squareup.okhttp:mockwebserver:2.7.5' androidTestImplementation 'commons-io:commons-io:2.6' - androidTestImplementation 'com.android.support.test:rules:1.0.2' + androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'com.google.android.gms:play-services-location:21.0.1' //Do not update to the latest library, Only 2.x compatible with Android M and below. androidTestImplementation 'org.assertj:assertj-core:2.9.1' @@ -140,6 +135,9 @@ dependencies { androidTestImplementation 'com.madgag.spongycastle:bcpkix-jdk15on:1.58.0.0' androidTestImplementation 'androidx.security:security-crypto:1.1.0-alpha06' + //App Integrity + androidTestImplementation 'com.google.android.play:integrity:1.3.0' + testImplementation 'androidx.test:core:1.5.0' testImplementation 'androidx.test.ext:junit:1.1.5' testImplementation 'androidx.test:runner:1.5.2' @@ -164,6 +162,10 @@ dependencies { //Application Pin testImplementation 'com.madgag.spongycastle:bcpkix-jdk15on:1.58.0.0' testImplementation 'androidx.security:security-crypto:1.1.0-alpha06' + + //App Integrity + testImplementation 'com.google.android.play:integrity:1.3.0' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2' testImplementation 'org.mockito:mockito-core:4.8.1' diff --git a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/AppIntegrityCallbackTest.java b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/AppIntegrityCallbackTest.java new file mode 100644 index 00000000..5cea36da --- /dev/null +++ b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/AppIntegrityCallbackTest.java @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.callback; + +import static org.assertj.core.api.Assertions.assertThat; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.assertj.core.api.Assertions; +import org.forgerock.android.auth.FRAuth; +import org.forgerock.android.auth.FRListener; +import org.forgerock.android.auth.FROptions; +import org.forgerock.android.auth.FROptionsBuilder; +import org.forgerock.android.auth.FRSession; +import org.forgerock.android.auth.Logger; +import org.forgerock.android.auth.Node; +import org.forgerock.android.auth.NodeListener; +import org.forgerock.android.auth.NodeListenerFuture; +import org.junit.After; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; +import org.junit.runner.RunWith; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +@RunWith(AndroidJUnit4.class) +public class AppIntegrityCallbackTest { + + protected static Context context = ApplicationProvider.getApplicationContext(); + + protected final static String AM_URL = "https://localam.petrov.ca/openam"; + protected final static String REALM = "root"; + protected final static String OAUTH_CLIENT = "AndroidTest"; + protected final static String OAUTH_REDIRECT_URI = "org.forgerock.demo:/oauth2redirect"; + protected final static String SCOPE = "openid profile email address phone"; + + protected final static String USERNAME = "sdkuser"; + protected final static String TREE = "TEST-app-integrity"; + + @Rule + public Timeout timeout = new Timeout(20000, TimeUnit.MILLISECONDS); + + @BeforeClass + public static void setUpSDK() { + Logger.set(Logger.Level.DEBUG); + + // Prepare dynamic configuration object + FROptions options = FROptionsBuilder.build(builder -> { + builder.server(serverBuilder -> { + serverBuilder.setUrl(AM_URL); + serverBuilder.setRealm(REALM); + return null; + }); + builder.oauth(oauth -> { + oauth.setOauthClientId(OAUTH_CLIENT); + oauth.setOauthRedirectUri(OAUTH_REDIRECT_URI); + oauth.setOauthScope(SCOPE); + return null; + }); + return null; + }); + + FRAuth.start(context, options); + } + + @After + public void logoutSession() { + if (FRSession.getCurrentSession() != null) { + FRSession.getCurrentSession().logout(); + } + } + + @Test + public void testAppIntegrityCallback() throws ExecutionException, InterruptedException { + // This test checks the returned callback from the App Integrity node + // Is also tests that the AppIntegrity node triggers correct "custom" client error outcome... (in this case "abort") + final int[] abort = {0}; + + NodeListenerFuture nodeListenerFuture = new AppIntegrityNodeListener(context, "default") { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) { + if (node.getCallback(AppIntegrityCallback.class) != null) { + AppIntegrityCallback callback = node.getCallback(AppIntegrityCallback.class); + + assertThat(callback.getRequestType()).isEqualTo(RequestType.CLASSIC); + assertThat(callback.getProjectNumber()).isEqualTo("684644441808"); + Assert.assertNotNull(callback.getNonce()); + + // Set "Abort" outcome... + callback.setClientError("Abort"); + node.next(context, nodeListener); + return; + } + if (node.getCallback(TextOutputCallback.class) != null) { + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + assertThat(callback.getMessage()).isEqualTo("Abort"); + abort[0]++; + node.next(context, nodeListener); + return; + } + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + + // Make sure AppIntegrity node fired the "abort" outcome + assertThat(abort[0]).isEqualTo(1); + } + + @Test + public void testAppIntegrityClassic() throws ExecutionException, InterruptedException { + // This test performs a CLASSIC api call + final int[] successClientCall = {0}; + final int[] failureOutcome = {0}; + + NodeListenerFuture nodeListenerFuture = new AppIntegrityNodeListener(context, "classic") { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) { + if (node.getCallback(AppIntegrityCallback.class) != null) { + AppIntegrityCallback callback = node.getCallback(AppIntegrityCallback.class); + + // Perform app integrity check + callback.requestIntegrityToken(context, new FRListener() { + @Override + public void onSuccess(Void result) { + successClientCall[0]++; + node.next(context, nodeListener); + + } + @Override + public void onException(Exception e) { + Assertions.fail("Unexpected failure during client app integrity call!"); + node.next(context, nodeListener); + } + }); + + return; + } + if (node.getCallback(TextOutputCallback.class) != null) { + // The node configuration in this case sets the verdict variable in the shared stated + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + assertThat(callback.getMessage()).isEqualTo("Failure"); + failureOutcome[0]++; + node.next(context, nodeListener); + return; + } + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + + assertThat(successClientCall[0]).isEqualTo(1); + assertThat(failureOutcome[0]).isEqualTo(1); + } + + @Test + public void testAppIntegrityStandard() throws ExecutionException, InterruptedException { + // This test performs a STANDARD api call + final int[] successClientCall = {0}; + final int[] failureOutcome = {0}; + + NodeListenerFuture nodeListenerFuture = new AppIntegrityNodeListener(context, "standard") { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) { + if (node.getCallback(AppIntegrityCallback.class) != null) { + AppIntegrityCallback callback = node.getCallback(AppIntegrityCallback.class); + + // Perform app integrity check + callback.requestIntegrityToken(context, new FRListener() { + @Override + public void onSuccess(Void result) { + successClientCall[0]++; + node.next(context, nodeListener); + + } + @Override + public void onException(Exception e) { + Assertions.fail("Unexpected failure during client app integrity call!"); + node.next(context, nodeListener); + } + }); + + return; + } + if (node.getCallback(TextOutputCallback.class) != null) { + // The node configuration in this case sets the verdict variable in the shared stated + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + assertThat(callback.getMessage()).isEqualTo("Failure"); + failureOutcome[0]++; + node.next(context, nodeListener); + return; + } + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + + assertThat(successClientCall[0]).isEqualTo(1); + assertThat(failureOutcome[0]).isEqualTo(1); + } + + @Test + public void testAppIntegrityVerdictVarON() throws ExecutionException, InterruptedException { + // This test checks if VERDICT variable is set in the shared stated when enabled... + final int[] verdictExists = {0}; + + NodeListenerFuture nodeListenerFuture = new AppIntegrityNodeListener(context, "verdict-on") { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) { + if (node.getCallback(AppIntegrityCallback.class) != null) { + AppIntegrityCallback callback = node.getCallback(AppIntegrityCallback.class); + + // Perform app integrity check + callback.requestIntegrityToken(context, new FRListener() { + @Override + public void onSuccess(Void result) { + node.next(context, nodeListener); + } + @Override + public void onException(Exception e) { + Assertions.fail("Unexpected failure during client app integrity call!"); + } + }); + return; + } + if (node.getCallback(TextOutputCallback.class) != null) { + // The node configuration in this case sets the verdict variable in the shared stated + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + assertThat(callback.getMessage()).isEqualTo("Verdict Exists"); + verdictExists[0]++; + node.next(context, nodeListener); + return; + } + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + + assertThat(verdictExists[0]).isEqualTo(1); + } + + @Test + public void testAppIntegrityVerdictVarOFF() throws ExecutionException, InterruptedException { + // This test checks if VERDICT variable is set in the shared stated - in this case the node configuration is set to OFF + final int[] verdictDoesNOTexist = {0}; + + NodeListenerFuture nodeListenerFuture = new AppIntegrityNodeListener(context, "verdict-off") { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) { + if (node.getCallback(AppIntegrityCallback.class) != null) { + AppIntegrityCallback callback = node.getCallback(AppIntegrityCallback.class); + + callback.requestIntegrityToken(context, new FRListener() { + @Override + public void onSuccess(Void result) { + node.next(context, nodeListener); + } + @Override + public void onException(Exception e) { + Assertions.fail("Unexpected failure during client app integrity call!"); + node.next(context, nodeListener); + } + }); + return; + } + if (node.getCallback(TextOutputCallback.class) != null) { + // The node configuration in this case sets the verdict variable in the shared stated + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + assertThat(callback.getMessage()).isEqualTo("Verdict DOES NOT exist"); + verdictDoesNOTexist[0]++; + node.next(context, nodeListener); + return; + } + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + + assertThat(verdictDoesNOTexist[0]).isEqualTo(1); + } + + @Test + public void testAppIntegrityClientError() throws ExecutionException, InterruptedException { + // Verifies that client errors a properly handled by the SDK and trigger the default outcome of the Integrity node + final int[] clientError = {0}; + + NodeListenerFuture nodeListenerFuture = new AppIntegrityNodeListener(context, "client-error") { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) { + if (node.getCallback(AppIntegrityCallback.class) != null) { + AppIntegrityCallback callback = node.getCallback(AppIntegrityCallback.class); + + // Perform app integrity check + callback.requestIntegrityToken(context, new FRListener() { + @Override + public void onSuccess(Void result) { + Assertions.fail("Unexpected successful integrity call!"); + node.next(context, nodeListener); + } + @Override + public void onException(Exception e) { + // Don't do much... + Logger.debug("testAppIntegrityClientError", e.getMessage()); + node.next(context, nodeListener); + } + }); + return; + } + if (node.getCallback(TextOutputCallback.class) != null) { + // The node configuration in this case sets the verdict variable in the shared stated + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + assertThat(callback.getMessage()).isEqualTo("Client Error"); + clientError[0]++; + node.next(context, nodeListener); + return; + } + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + + assertThat(clientError[0]).isEqualTo(1); + } +} + + + diff --git a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/AppIntegrityNodeListener.java b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/AppIntegrityNodeListener.java new file mode 100644 index 00000000..c08ff658 --- /dev/null +++ b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/AppIntegrityNodeListener.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.callback; + +import static org.forgerock.android.auth.AndroidBaseTest.USERNAME; + +import android.content.Context; + +import org.forgerock.android.auth.FRSession; +import org.forgerock.android.auth.Node; +import org.forgerock.android.auth.NodeListenerFuture; + +import java.util.List; + +public class AppIntegrityNodeListener extends NodeListenerFuture { + + private Context context; + private String nodeConfiguration; + + public AppIntegrityNodeListener(Context context, String nodeConfiguration) { + this.context = context; + this.nodeConfiguration = nodeConfiguration; + } + + @Override + public void onCallbackReceived(Node node) { + if (node.getCallback(ChoiceCallback.class) != null) { + ChoiceCallback choiceCallback = node.getCallback(ChoiceCallback.class); + List choices = choiceCallback.getChoices(); + int choiceIndex = choices.indexOf(nodeConfiguration); + choiceCallback.setSelectedIndex(choiceIndex); + node.next(context, this); + } + if (node.getCallback(NameCallback.class) != null) { + node.getCallback(NameCallback.class).setName(USERNAME); + node.next(context, this); + } + } +} diff --git a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/BaseDeviceBindingTest.java b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/BaseDeviceBindingTest.java index 4789d81c..9d297f92 100644 --- a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/BaseDeviceBindingTest.java +++ b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/BaseDeviceBindingTest.java @@ -31,7 +31,7 @@ public abstract class BaseDeviceBindingTest { protected static Context context = ApplicationProvider.getApplicationContext(); // This test uses dynamic configuration with the following settings: - protected final static String AM_URL = "https://openam-dbind.forgeblocks.com/am"; + protected final static String AM_URL = "https://openam-sdks.forgeblocks.com/am"; protected final static String REALM = "alpha"; protected final static String OAUTH_CLIENT = "AndroidTest"; protected final static String OAUTH_REDIRECT_URI = "org.forgerock.demo:/oauth2redirect"; diff --git a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceBindingCallbackTest.java b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceBindingCallbackTest.java index 8bc4e77d..af9c6bfd 100644 --- a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceBindingCallbackTest.java +++ b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceBindingCallbackTest.java @@ -22,14 +22,12 @@ import org.forgerock.android.auth.NodeListenerFuture; import org.forgerock.android.auth.devicebind.ApplicationPinDeviceAuthenticator; import org.junit.Assert; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import java.util.concurrent.ExecutionException; @RunWith(AndroidJUnit4.class) -@Ignore public class DeviceBindingCallbackTest extends BaseDeviceBindingTest { protected final static String TREE = "device-bind"; @@ -43,7 +41,7 @@ public void onCallbackReceived(Node node) { if (node.getCallback(DeviceBindingCallback.class) != null) { DeviceBindingCallback callback = node.getCallback(DeviceBindingCallback.class); Assert.assertNotNull(callback.getUserId()); - assertThat(callback.getUserName()).isEqualTo(USERNAME); +// assertThat(callback.getUserName()).isEqualTo(USERNAME); assertThat(callback.getDeviceBindingAuthenticationType()).isEqualTo(DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK); Assert.assertNotNull(callback.getChallenge()); assertThat(callback.getTitle()).isEqualTo("Authentication required"); @@ -79,7 +77,7 @@ public void onCallbackReceived(Node node) { if (node.getCallback(DeviceBindingCallback.class) != null) { DeviceBindingCallback callback = node.getCallback(DeviceBindingCallback.class); Assert.assertNotNull(callback.getUserId()); - assertThat(callback.getUserName()).isEqualTo(USERNAME); +// assertThat(callback.getUserName()).isEqualTo(USERNAME); assertThat(callback.getDeviceBindingAuthenticationType()).isEqualTo(DeviceBindingAuthenticationType.NONE); Assert.assertNotNull(callback.getChallenge()); assertThat(callback.getTitle()).isEqualTo("Custom title"); @@ -299,6 +297,55 @@ public void onException(Exception e) { assertThat(bindSuccess[0]).isEqualTo(1); assertThat(executionExceptionOccurred).isTrue(); } + + @Test + public void testDeviceBindingDeviceDataVariable() throws ExecutionException, InterruptedException { + // This test is to ensure that the Device Binding node sets DeviceBinding.DEVICE variable in shared state + final int[] bindSuccess = {0}; + final int[] deviceDataVarPresentInAM = {0}; + NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "device-data-var") { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) { + if (node.getCallback(DeviceBindingCallback.class) != null) { + DeviceBindingCallback callback = node.getCallback(DeviceBindingCallback.class); + + callback.bind(context, new FRListener() { + @Override + public void onSuccess(Void result) { + node.next(context, nodeListener); + bindSuccess[0]++; + } + + @Override + public void onException(Exception e) { + Assertions.fail(e.getMessage()); + } + }); + return; + } + if (node.getCallback(TextOutputCallback.class) != null) { + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + // Check the DeviceBinding.DEVICE variable in AM... + assertThat(callback.getMessage()).isEqualTo("Device data variable exists"); + deviceDataVarPresentInAM[0]++; + node.next(context, nodeListener); + return; + } + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + assertThat(bindSuccess[0]).isEqualTo(1); + assertThat(deviceDataVarPresentInAM[0]).isEqualTo(1); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + } } diff --git a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceBindingListAndUnbind.java b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceBindingListAndUnbind.java index 25327824..672989cc 100644 --- a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceBindingListAndUnbind.java +++ b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceBindingListAndUnbind.java @@ -25,13 +25,11 @@ import org.forgerock.android.auth.devicebind.UserKey; import org.forgerock.android.auth.exception.ApiException; import org.junit.Assert; -import org.junit.Ignore; import org.junit.Test; import java.io.IOException; import java.util.List; import java.util.concurrent.ExecutionException; -@Ignore public class DeviceBindingListAndUnbind extends BaseDeviceBindingTest { protected final static String TREE = "device-verifier"; protected final static String APPLICATION_PIN = "1234"; @@ -170,7 +168,7 @@ public void testListAndDeleteKeys() throws ExecutionException, InterruptedExcept assertThat(userKeys.loadAll().size()).isEqualTo(1); // Assert that the keys are indeed associated with the correct user - assertThat(userKeys.loadAll().get(0).getUserName()).isEqualTo(USERNAME); +// assertThat(userKeys.loadAll().get(0).getUserName()).isEqualTo(USERNAME); // Attempt to remove a key without being authenticated with it should fail FRListenerFuture future = new FRListenerFuture(); @@ -236,7 +234,7 @@ public void testForceDelete() throws ExecutionException, InterruptedException { assertThat(userKeys.loadAll().size()).isEqualTo(1); // Assert that the keys are indeed associated with the correct user - assertThat(userKeys.loadAll().get(0).getUserName()).isEqualTo(USERNAME); +// assertThat(userKeys.loadAll().get(0).getUserName()).isEqualTo(USERNAME); // Attempt to remove a key without being authenticated with it should fail FRListenerFuture future = new FRListenerFuture(); diff --git a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceSigningVerifierApplicationPinCallbackTest.java b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceSigningVerifierApplicationPinCallbackTest.java index 13b3189c..29b11c5a 100644 --- a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceSigningVerifierApplicationPinCallbackTest.java +++ b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceSigningVerifierApplicationPinCallbackTest.java @@ -27,14 +27,13 @@ import org.forgerock.android.auth.devicebind.DefaultUserKeySelector; import org.junit.Assert; import org.junit.BeforeClass; -import org.junit.Ignore; import org.junit.Test; import java.text.ParseException; import java.util.Calendar; +import java.util.Collections; import java.util.Date; import java.util.concurrent.ExecutionException; -@Ignore public class DeviceSigningVerifierApplicationPinCallbackTest extends BaseDeviceBindingTest { protected final static String TREE = "device-verifier"; protected final static String APPLICATION_PIN = "1234"; @@ -114,7 +113,7 @@ public void onCallbackReceived(Node node) { Assert.assertNotNull(callback.getUserId()); Assert.assertNotNull(callback.getChallenge()); - callback.sign(context, new DefaultUserKeySelector(), + callback.sign(context, Collections.emptyMap(), new DefaultUserKeySelector(), deviceBindingAuthenticationType -> new ApplicationPinDeviceAuthenticator((prompt, fragmentActivity, $completion) -> APPLICATION_PIN.toCharArray()), new FRListener() { @Override @@ -194,7 +193,7 @@ public void onCallbackReceived(Node node) { Assert.assertNotNull(callback.getUserId()); Assert.assertNotNull(callback.getChallenge()); - callback.sign(context, new DefaultUserKeySelector(), + callback.sign(context, Collections.emptyMap(), new DefaultUserKeySelector(), deviceBindingAuthenticationType -> new ApplicationPinDeviceAuthenticator((prompt, fragmentActivity, $completion) -> "WRONG".toCharArray()), new FRListener() { @Override @@ -250,7 +249,7 @@ public void onCallbackReceived(Node node) { assertThat(callback.getUserId()).isEmpty(); Assert.assertNotNull(callback.getChallenge()); - callback.sign(context, new DefaultUserKeySelector(), + callback.sign(context, Collections.emptyMap(), new DefaultUserKeySelector(), deviceBindingAuthenticationType -> new ApplicationPinDeviceAuthenticator((prompt, fragmentActivity, $completion) -> APPLICATION_PIN.toCharArray()), new FRListener() { @Override diff --git a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallbackTest.java b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallbackTest.java index 9695714e..e32a0b07 100644 --- a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallbackTest.java +++ b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallbackTest.java @@ -17,6 +17,7 @@ import com.nimbusds.jwt.JWTParser; import org.assertj.core.api.Assertions; +import org.assertj.core.api.ClassAssert; import org.forgerock.android.auth.FRListener; import org.forgerock.android.auth.FRSession; import org.forgerock.android.auth.Logger; @@ -27,17 +28,17 @@ import org.json.JSONObject; import org.junit.Assert; import org.junit.BeforeClass; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import java.text.ParseException; import java.util.Calendar; import java.util.Date; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.ExecutionException; @RunWith(AndroidJUnit4.class) -@Ignore public class DeviceSigningVerifierCallbackTest extends BaseDeviceBindingTest { protected final static String TREE = "device-verifier"; @@ -243,6 +244,10 @@ public void onCallbackReceived(Node node) public void onSuccess(Void result) { // Verify the JWT attributes try { + Calendar nowMinus5 = Calendar.getInstance(); + Calendar nowPlus5 = Calendar.getInstance(); + nowMinus5.add(Calendar.SECOND, -5); + nowPlus5.add(Calendar.SECOND, 5); Calendar expMin = Calendar.getInstance(); Calendar expMax = Calendar.getInstance(); expMin.add(Calendar.SECOND, 55); @@ -251,11 +256,15 @@ public void onSuccess(Void result) { JWT jwt = JWTParser.parse((String) callback.getInputValue(0)); String jwtKid = jwt.getHeader().toJSONObject().get("kid").toString(); Date jwtExp = jwt.getJWTClaimsSet().getExpirationTime(); + Date jwtIat = jwt.getJWTClaimsSet().getIssueTime(); + Date jwtNbf = jwt.getJWTClaimsSet().getNotBeforeTime(); String jwtChallenge = jwt.getJWTClaimsSet().getStringClaim("challenge"); String jwtSub = jwt.getJWTClaimsSet().getSubject(); assertThat(jwtKid).isEqualTo(KID); assertThat(jwtExp).isBetween(expMin.getTime(), expMax.getTime()); + assertThat(jwtIat).isBetween(nowMinus5.getTime(), nowPlus5.getTime()); + assertThat(jwtNbf).isBetween(nowMinus5.getTime(), nowPlus5.getTime()); assertThat(jwtChallenge).isEqualTo(callback.getChallenge()); assertThat(jwtSub).isEqualTo(callback.getUserId()); @@ -772,4 +781,136 @@ public void onException(Exception e) { assertThat(executionExceptionOccurred).isTrue(); } + @Test + public void testDeviceVerificationCustomClaims() throws ExecutionException, InterruptedException { + final int[] signSuccess = {0}; + final int[] claimsPresentInAM = {0}; + + NodeListenerFuture nodeListenerFuture = new DeviceSigningVerifierNodeListener(context, "custom-claims") + { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) + { + if (node.getCallback(DeviceSigningVerifierCallback.class) != null) { + DeviceSigningVerifierCallback callback = node.getCallback(DeviceSigningVerifierCallback.class); + + Map customClaims = new HashMap(); + customClaims.put("foo", "bar"); + customClaims.put("num", 5); + customClaims.put("isGood", true); + + callback.sign(context, customClaims, new FRListener() { + @Override + public void onSuccess(Void result) { + // Verify the JWT contains the custom claims + try { + JWT jwt = JWTParser.parse((String) callback.getInputValue(0)); + String jwtFooClaim = jwt.getJWTClaimsSet().getStringClaim("foo"); + assertThat(jwtFooClaim).isEqualTo("bar"); + + Integer jwtNumClaim = jwt.getJWTClaimsSet().getIntegerClaim ("num"); + assertThat(jwtNumClaim).isEqualTo(5); + + Boolean jwtIsGoodlaim = jwt.getJWTClaimsSet().getBooleanClaim("isGood"); + assertThat(jwtIsGoodlaim).isEqualTo(true); + } catch (ParseException e) { + Assertions.fail("Invalid JWT: " + e.getMessage()); + } + signSuccess[0]++; + node.next(context, nodeListener); + } + @Override + public void onException(Exception e) { + // Signing of the challenge has failed unexpectedly... + Assertions.fail(e.getMessage()); + } + }); + + return; + } + if (node.getCallback(TextOutputCallback.class) != null) { + TextOutputCallback textOutputCallback = node.getCallback(TextOutputCallback.class); + // Check the DeviceSigningVerifierNode.JWT variable in AM... + assertThat(textOutputCallback.getMessage()).isEqualTo("Custom claims exist"); + claimsPresentInAM[0]++; + + node.next(context, nodeListener); + return; + } + + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + assertThat(signSuccess[0]).isEqualTo(1); + assertThat(claimsPresentInAM[0]).isEqualTo(1); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + } + + @Test + public void testDeviceVerificationInvalidCustomClaims() throws ExecutionException, InterruptedException { + final int[] signSuccess = {0}; + + NodeListenerFuture nodeListenerFuture = new DeviceSigningVerifierNodeListener(context, "custom-claims") + { + final NodeListener nodeListener = this; + + @Override + public void onCallbackReceived(Node node) + { + if (node.getCallback(DeviceSigningVerifierCallback.class) != null) { + DeviceSigningVerifierCallback callback = node.getCallback(DeviceSigningVerifierCallback.class); + + Map customClaims = new HashMap(); + // This "iss" claim should trigger exception upon signing + customClaims.put("iss", "foo"); + + callback.sign(context, customClaims, new FRListener() { + @Override + public void onSuccess(Void result) { + signSuccess[0]++; + node.next(context, nodeListener); + } + + @Override + public void onException(Exception e) { + assertThat(e.getClass().getName()).isEqualTo("org.forgerock.android.auth.devicebind.DeviceBindingException");; + assertThat(e.getMessage()).isEqualTo("Invalid Custom Claims"); + + node.next(context, nodeListener); + } + + }); + + return; + } + // Make sure that by default upon + if (node.getCallback(TextOutputCallback.class) != null) { + TextOutputCallback textOutputCallback = node.getCallback(TextOutputCallback.class); + assertThat(textOutputCallback.getMessage()).isEqualTo("Abort"); + + node.next(context, nodeListener); + return; + } + + super.onCallbackReceived(node); + } + }; + + FRSession.authenticate(context, TREE, nodeListenerFuture); + Assert.assertNotNull(nodeListenerFuture.get()); + assertThat(signSuccess[0]).isEqualTo(0); + + // Ensure that the journey finishes with success + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); + } + } diff --git a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/KeyAttestationTest.java b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/KeyAttestationTest.java index 379993e8..5824f429 100644 --- a/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/KeyAttestationTest.java +++ b/forgerock-auth/src/androidTest/java/org/forgerock/android/auth/callback/KeyAttestationTest.java @@ -29,24 +29,23 @@ import org.forgerock.android.auth.NodeListenerFuture; import org.forgerock.android.auth.devicebind.ApplicationPinDeviceAuthenticator; import org.junit.Assert; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import java.text.ParseException; +import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; @RunWith(AndroidJUnit4.class) -@Ignore public class KeyAttestationTest extends BaseDeviceBindingTest { protected final static String TREE = "key-attestation"; @Test - public void testKeyAttestationNoneNone() throws ExecutionException, InterruptedException { - // Test that when "Key Attestation" is set to NONE in AM, the SDK does not include x5c (X.509 Certificate Chain) parameter in the JWK... + public void testKeyAttestationNoneAttestationOff() throws ExecutionException, InterruptedException { + // Test that when "Key Attestation" is OFF in AM, the SDK does not include x5c (X.509 Certificate Chain) parameter in the JWK... final int[] bindSuccess = {0}; - NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "none-none") { + NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "none-attestation-off") { final NodeListener nodeListener = this; @Override @@ -102,10 +101,10 @@ public void onException(Exception e) { } @Test - public void testKeyAttestationNoneDefault() throws ExecutionException, InterruptedException { - // Make sure that when "Key Attestation" is set to DEFAULT in AM, the SDK includes x5c (X.509 Certificate Chain) parameter... + public void testKeyAttestationNoneAttestationOn() throws ExecutionException, InterruptedException { + // Make sure that when "Key Attestation" is ON in AM, the SDK includes x5c (X.509 Certificate Chain) parameter... final int[] bindSuccess = {0}; - NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "none-default") { + NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "none-attestation-on") { final NodeListener nodeListener = this; @Override @@ -160,11 +159,13 @@ public void onException(Exception e) { } @Test - public void testKeyAttestationNoneCustomPass() throws ExecutionException, InterruptedException { - // Make sure that when "Key Attestation" is set to CUSTOM in AM, the SDK includes x5c (X.509 Certificate Chain) parameter... - // Make sure that when the custom script returns "true", the device binding node outcome is success... + public void testKeyAttestationTransientStateVariable() throws ExecutionException, InterruptedException { + // Ensure that when Key Attestation toggle button is enabled in the Device Binding node, + // Key Attestation Validation will be performed, and the extension data will be put into the transient state with the variable + // DeviceBindingCallback.ATTESTATION final int[] bindSuccess = {0}; - NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "none-custom-pass") { + final int[] attestationVarExists = {0}; + NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "attestation-var-set") { final NodeListener nodeListener = this; @Override @@ -177,7 +178,7 @@ public void onCallbackReceived(Node node) { public void onSuccess(Void result) { bindSuccess[0]++; - // Validate the jwt sent to AM... + // Validate the jwt sent to AM includes attestation data try { JWT jwt = JWTParser.parse((String) callback.getInputValue(0)); String jwkAlg = ((SignedJWT) jwt).getHeader().getJWK().getAlgorithm().toString(); @@ -205,6 +206,13 @@ public void onException(Exception e) { }); return; } + if (node.getCallback(TextOutputCallback.class) != null) { + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + assertThat(callback.getMessage()).isEqualTo("Attestation var exists"); + attestationVarExists[0]++; + node.next(context, nodeListener); + return; + } super.onCallbackReceived(node); } }; @@ -212,21 +220,20 @@ public void onException(Exception e) { FRSession.authenticate(context, TREE, nodeListenerFuture); Assert.assertNotNull(nodeListenerFuture.get()); assertThat(bindSuccess[0]).isEqualTo(1); /// Make sure that device binding was successful... + assertThat(attestationVarExists[0]).isEqualTo(1); /// Make sure that attestation variable exists in transient state - // Ensure that the journey finishes with success + // Ensure that the journey finishes with success - this also means that the attestation transient state variable is set in AM! Assert.assertNotNull(FRSession.getCurrentSession()); Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); } @Test - public void testKeyAttestationNoneCustomFail() { - // Make sure that when "Key Attestation" is set to CUSTOM in AM, the SDK includes x5c (X.509 Certificate Chain) parameter... - // Make sure that when the custom script returns "false", the device binding node outcome is failure... + public void testKeyAttestationTransientStateVariableNull() throws ExecutionException, InterruptedException { + // Ensure that when Key Attestation toggle button is NOT enabled in the Device Binding node, + // Key Attestation Validation will NOT be performed- transient variable DeviceBindingCallback.ATTESTATION should be null! final int[] bindSuccess = {0}; - final int[] failureOutcome = {0}; - boolean executionExceptionOccurred = false; - - NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "none-custom-fail") { + final int[] attestationVarDoesNotExist = {0}; + NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "attestation-var-null") { final NodeListener nodeListener = this; @Override @@ -239,7 +246,7 @@ public void onCallbackReceived(Node node) { public void onSuccess(Void result) { bindSuccess[0]++; - // Validate the jwt sent to AM... + // Validate the jwt sent to AM does NOT include attestation data try { JWT jwt = JWTParser.parse((String) callback.getInputValue(0)); String jwkAlg = ((SignedJWT) jwt).getHeader().getJWK().getAlgorithm().toString(); @@ -248,12 +255,7 @@ public void onSuccess(Void result) { assertThat(jwkAlg).isEqualTo("RS512"); assertThat(jwkUse).isEqualTo("sig"); - assertThat(x5c).isNotNull(); // When Android Key Attestation is set to CUSTOM in AM - - /// Assert some other properties - assertThat(jwt.getJWTClaimsSet().getClaim("iss")).isEqualTo("org.forgerock.android.auth.test"); - assertThat(jwt.getJWTClaimsSet().getClaim("platform")).isEqualTo("android"); - assertThat(jwt.getJWTClaimsSet().getClaim("android-version")).isEqualTo(Long.valueOf(Build.VERSION.SDK_INT)); + assertThat(x5c).isNull(); // When Android Key Attestation is NULL } catch (ParseException e) { throw new RuntimeException(e); } @@ -262,17 +264,15 @@ public void onSuccess(Void result) { @Override public void onException(Exception e) { - Assert.fail("Unexpected failure."); - node.next(context, nodeListener); + Assertions.fail(e.getMessage()); } }); return; } if (node.getCallback(TextOutputCallback.class) != null) { - TextOutputCallback textOutputCallback = node.getCallback(TextOutputCallback.class); - assertThat(textOutputCallback.getMessage()).isEqualTo("Failure"); - failureOutcome[0]++; - + TextOutputCallback callback = node.getCallback(TextOutputCallback.class); + assertThat(callback.getMessage()).isEqualTo("Attestation var DOES NOT exist"); + attestationVarDoesNotExist[0]++; node.next(context, nodeListener); return; } @@ -281,32 +281,25 @@ public void onException(Exception e) { }; FRSession.authenticate(context, TREE, nodeListenerFuture); - // Ensure that the journey finishes with failure - try { - Assert.assertNull(nodeListenerFuture.get()); - } catch (ExecutionException e) { - executionExceptionOccurred = true; - assertThat(e.getMessage()).isEqualTo("ApiException{statusCode=401, error='', description='{\"code\":401,\"reason\":\"Unauthorized\",\"message\":\"Login failure\"}'}"); - } catch (InterruptedException e) { - Assert.fail("Unexpected exception."); - } - Assert.assertNull(FRSession.getCurrentSession()); + Assert.assertNotNull(nodeListenerFuture.get()); + assertThat(bindSuccess[0]).isEqualTo(1); /// Make sure that device binding was successful... + assertThat(attestationVarDoesNotExist[0]).isEqualTo(1); /// Make sure that attestation variable does NOT exist in transient state - assertThat(bindSuccess[0]).isEqualTo(1); - assertThat(failureOutcome[0]).isEqualTo(1); - assertThat(executionExceptionOccurred).isTrue(); + // Ensure that the journey finishes with success - this also means that the attestation transient state variable is set in AM! + Assert.assertNotNull(FRSession.getCurrentSession()); + Assert.assertNotNull(FRSession.getCurrentSession().getSessionToken()); } @Test - public void testKeyAttestationApplicationPinNone() throws ExecutionException, InterruptedException { - // Test that when authentication type is set to APPLICATION_PIN and Key Attestation is NONE, device binding outcome is 'success'... + public void testKeyAttestationApplicationPinAttestationOff() throws ExecutionException, InterruptedException { + // Test that when authentication type is set to APPLICATION_PIN and Key Attestation is OFF, device binding outcome is 'success'... // Make sure that the SDK DOES NOT include attestation data in the JWT... final int[] bindSuccess = {0}; ActivityScenario scenario = ActivityScenario.launch(DummyActivity.class); scenario.onActivity(InitProvider::setCurrentActivity); - NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "pin-none") { + NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "pin-attestation-off") { final NodeListener nodeListener = this; @Override @@ -364,14 +357,14 @@ public void onException(Exception e) { } @Test - public void testKeyAttestationApplicationPinDefault() { - // Test that when authentication type is set to APPLICATION_PIN and Key Attestation is DEFAULT, device binding outcome is 'unsupported'... + public void testKeyAttestationApplicationPinAttestationOn() { + // Test that when authentication type is set to APPLICATION_PIN and Key Attestation is ON, device binding outcome is 'unsupported'... final int[] bindSuccess = {0}; final int[] bindFail = {0}; final int[] unsupportedOutcome = {0}; boolean executionExceptionOccurred = false; - NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "pin-default") { + NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "pin-attestation-on") { final NodeListener nodeListener = this; @Override @@ -426,68 +419,6 @@ public void onException(Exception e) { assertThat(executionExceptionOccurred).isTrue(); } - @Test - public void testKeyAttestationApplicationPinCustom() { - // Test that when authentication type is set to APPLICATION_PIN and Key Attestation is CUSTOM, device binding outcome is 'unsupported'... - final int[] bindSuccess = {0}; - final int[] bindFail = {0}; - final int[] unsupportedOutcome = {0}; - boolean executionExceptionOccurred = false; - - NodeListenerFuture nodeListenerFuture = new DeviceBindingNodeListener(context, "pin-custom") { - final NodeListener nodeListener = this; - - @Override - public void onCallbackReceived(Node node) { - if (node.getCallback(DeviceBindingCallback.class) != null) { - DeviceBindingCallback callback = node.getCallback(DeviceBindingCallback.class); - - callback.bind(context, deviceBindingAuthenticationType -> - new ApplicationPinDeviceAuthenticator((prompt, fragmentActivity, $completion) -> "1234".toCharArray()), - new FRListener() { - @Override - public void onSuccess(Void result) { - bindSuccess[0]++; - node.next(context, nodeListener); - } - - @Override - public void onException(Exception e) { - bindFail[0]++; - assertThat(e.getMessage()).isEqualTo("Device not supported. Please verify the biometric or Pin settings"); - node.next(context, nodeListener); - } - }); - return; - } - if (node.getCallback(TextOutputCallback.class) != null) { - TextOutputCallback textOutputCallback = node.getCallback(TextOutputCallback.class); - assertThat(textOutputCallback.getMessage()).isEqualTo("Unsupported"); - unsupportedOutcome[0]++; - node.next(context, nodeListener); - return; - } - super.onCallbackReceived(node); - } - }; - - FRSession.authenticate(context, TREE, nodeListenerFuture); - // Ensure that the journey finishes with failure - try { - Assert.assertNull(nodeListenerFuture.get()); - } catch (ExecutionException e) { - executionExceptionOccurred = true; - assertThat(e.getMessage()).isEqualTo("ApiException{statusCode=401, error='', description='{\"code\":401,\"reason\":\"Unauthorized\",\"message\":\"Login failure\"}'}"); - } catch (InterruptedException e) { - Assert.fail("Unexpected exception."); - } - Assert.assertNull(FRSession.getCurrentSession()); - - assertThat(bindSuccess[0]).isEqualTo(0); - assertThat(bindFail[0]).isEqualTo(1); - assertThat(unsupportedOutcome[0]).isEqualTo(1); - assertThat(executionExceptionOccurred).isTrue(); - } } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/ConfigHelper.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/ConfigHelper.kt index e5fd52a1..4f5f8e86 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/ConfigHelper.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/ConfigHelper.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -66,20 +66,20 @@ internal class ConfigHelper { return FROptionsBuilder.build { server { - url = sharedPreferences.getString(ConfigHelper.url, null) ?: "" - realm = sharedPreferences.getString(ConfigHelper.realm, null) ?: "" - cookieName = sharedPreferences.getString(ConfigHelper.cookieName, null) ?: "" + url = sharedPreferences.getString(ConfigHelper.url, null) ?: context.getString(R.string.forgerock_url) + realm = sharedPreferences.getString(ConfigHelper.realm, null) ?: context.getString(R.string.forgerock_realm) + cookieName = sharedPreferences.getString(ConfigHelper.cookieName, null) ?: context.getString(R.string.forgerock_cookie_name) } oauth { - oauthClientId = sharedPreferences.getString(ConfigHelper.clientId, null) ?: "" - oauthScope = sharedPreferences.getString(ConfigHelper.scope, null) ?: "" - oauthRedirectUri = sharedPreferences.getString(ConfigHelper.redirectUri, null) ?: "" + oauthClientId = sharedPreferences.getString(ConfigHelper.clientId, null) ?: context.getString(R.string.forgerock_oauth_client_id) + oauthScope = sharedPreferences.getString(ConfigHelper.scope, null) ?: context.getString(R.string.forgerock_oauth_scope) + oauthRedirectUri = sharedPreferences.getString(ConfigHelper.redirectUri, null) ?: context.getString(R.string.forgerock_oauth_redirect_uri) } urlPath { endSessionEndpoint = - sharedPreferences.getString(ConfigHelper.endSessionEndpoint, null) - revokeEndpoint = sharedPreferences.getString(ConfigHelper.revokeEndpoint, null) - sessionEndpoint = sharedPreferences.getString(ConfigHelper.sessionEndpoint, null) + sharedPreferences.getString(ConfigHelper.endSessionEndpoint, null) ?: context.getString(R.string.forgerock_endsession_endpoint) + revokeEndpoint = sharedPreferences.getString(ConfigHelper.revokeEndpoint, null) ?: context.getString(R.string.forgerock_revoke_endpoint) + sessionEndpoint = sharedPreferences.getString(ConfigHelper.sessionEndpoint, null) ?: context.getString(R.string.forgerock_session_endpoint) } } } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/DefaultSingleSignOnManager.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/DefaultSingleSignOnManager.java index 2ceaa7a3..01442bb5 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/DefaultSingleSignOnManager.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/DefaultSingleSignOnManager.java @@ -91,7 +91,6 @@ public boolean hasToken() { @Override public void revoke(final FRListener listener) { - SSOToken token = getToken(); if (token == null) { Listener.onException(listener, new IllegalStateException("SSO Token not found.")); diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/FRAuth.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/FRAuth.java index 795321b3..52db439d 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/FRAuth.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/FRAuth.java @@ -47,6 +47,8 @@ public static synchronized void start(Context context, @Nullable FROptions optio if(!started || !FROptions.equals(cachedOptions, options)) { started = true; FROptions currentOptions = ConfigHelper.load(context, options); + //Validate (AM URL, Realm, CookieName) is not Empty. If its empty will throw IllegalArgumentException. + currentOptions.validateConfig(); if (ConfigHelper.isConfigDifferentFromPersistedValue(context, currentOptions)) { SessionManager sessionManager = ConfigHelper.getPersistedConfig(context, cachedOptions).getSessionManager(); sessionManager.close(); diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/FROptions.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/FROptions.kt index ef2af536..35f7df57 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/FROptions.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/FROptions.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -34,6 +34,13 @@ data class FROptions(val server: Server, && old?.logger == new?.logger } } + @Throws(IllegalArgumentException::class) + @JvmName("validateConfig") + internal fun validateConfig() { + require(server.url.isNotBlank()) { "AM URL cannot be blank" } + require(server.realm.isNotBlank()) { "Realm cannot be blank" } + require(server.cookieName.isNotBlank()) { "cookieName cannot be blank" } + } } /** @@ -41,7 +48,7 @@ data class FROptions(val server: Server, */ class FROptionsBuilder { - private var server: Server = Server("", "") + private lateinit var server: Server private var oauth: OAuth = OAuth() private var service: Service = Service() private var urlPath: UrlPath = UrlPath() @@ -117,7 +124,7 @@ class FROptionsBuilder { * Data class for the server configurations */ data class Server(val url: String, - val realm: String, + val realm: String = "root", val timeout: Int = 30, val cookieName: String = "iPlanetDirectoryPro", val cookieCacheSeconds: Long = 0) @@ -127,12 +134,13 @@ data class Server(val url: String, */ class ServerBuilder { lateinit var url: String - lateinit var realm: String + var realm: String = "root" var timeout: Int = 30 var cookieName: String = "iPlanetDirectoryPro" var cookieCacheSeconds: Long = 0 - - fun build(): Server = Server(url, realm, timeout, cookieName, cookieCacheSeconds) + fun build(): Server { + return Server(url, realm, timeout, cookieName, cookieCacheSeconds) + } } /** diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/Node.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/Node.java index 630b63aa..53b384cb 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/Node.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/Node.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -9,6 +9,8 @@ import android.content.Context; +import androidx.annotation.VisibleForTesting; + import org.forgerock.android.auth.callback.Callback; import org.json.JSONArray; import org.json.JSONException; @@ -18,11 +20,6 @@ import java.io.Serializable; import java.util.List; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Getter public class Node implements Serializable { public static final String AUTH_ID = "authId"; @@ -38,6 +35,16 @@ public class Node implements Serializable { private final String authServiceId; private final List callbacks; + @VisibleForTesting + public Node(String authId, String stage, String header, String description, String authServiceId, List callbacks) { + this.authId = authId; + this.stage = stage; + this.header = header; + this.description = description; + this.authServiceId = authServiceId; + this.callbacks = callbacks; + } + /** * Returns {@link JSONObject} mapping of the object * @@ -76,6 +83,11 @@ public T getCallback(Class clazz) { return null; } + /** + * Retrieve all the {@link Callback}. + * + * @return All the {@link Callback} associate with this {@link Node} + */ public List getCallbacks() { return callbacks; } @@ -85,20 +97,19 @@ public List getCallbacks() { * * @param context The Application Context * @param listener Listener for receiving {@link AuthService} related changes - * {@link NodeListener#onSuccess(Object)} on success login. - * {@link NodeListener#onCallbackReceived(Node)} step to the next node, {@link Node} is returned. - * throws {@link IllegalStateException} when the tree is invalid, e.g the authentication tree has been completed. - * throws {@link org.forgerock.android.auth.exception.AuthenticationException} when server returns {@link java.net.HttpURLConnection#HTTP_UNAUTHORIZED} - * throws {@link org.forgerock.android.auth.exception.ApiException} When server return errors. - * throws {@link javax.security.auth.callback.UnsupportedCallbackException} - * When {@link org.forgerock.android.auth.callback.Callback} returned from Server is not supported by the SDK. - * throws {@link org.forgerock.android.auth.exception.SuspendedAuthSessionException} When Suspended ID timeout - * throws {@link org.forgerock.android.auth.exception.AuthenticationTimeoutException} When Authentication tree timeout - * throws {@link org.json.JSONException} when failed to parse server response as JSON String. - * throws {@link IOException } When there is any network error. - * throws {@link java.net.MalformedURLException} When failed to parse the URL for API request. - * throws {@link NoSuchMethodException} or {@link SecurityException} When failed to initialize the Callback class. - + * {@link NodeListener#onSuccess(Object)} on success login. + * {@link NodeListener#onCallbackReceived(Node)} step to the next node, {@link Node} is returned. + * throws {@link IllegalStateException} when the tree is invalid, e.g the authentication tree has been completed. + * throws {@link org.forgerock.android.auth.exception.AuthenticationException} when server returns {@link java.net.HttpURLConnection#HTTP_UNAUTHORIZED} + * throws {@link org.forgerock.android.auth.exception.ApiException} When server return errors. + * throws {@link javax.security.auth.callback.UnsupportedCallbackException} + * When {@link org.forgerock.android.auth.callback.Callback} returned from Server is not supported by the SDK. + * throws {@link org.forgerock.android.auth.exception.SuspendedAuthSessionException} When Suspended ID timeout + * throws {@link org.forgerock.android.auth.exception.AuthenticationTimeoutException} When Authentication tree timeout + * throws {@link org.json.JSONException} when failed to parse server response as JSON String. + * throws {@link IOException } When there is any network error. + * throws {@link java.net.MalformedURLException} When failed to parse the URL for API request. + * throws {@link NoSuchMethodException} or {@link SecurityException} When failed to initialize the Callback class. */ public void next(Context context, NodeListener listener) { AuthService.goToNext(context, this, listener); @@ -123,4 +134,43 @@ public void setCallback(Callback callback) { } } + /** + * Retrieve the AuthId. + * + * @return The AuthId attribute associate with this {@link Node} + */ + public String getAuthId() { + return this.authId; + } + + /** + * Retrieve the Stage. + * + * @return The Stage attribute associate with this {@link Node} + */ + public String getStage() { + return this.stage; + } + + /** + * Retrieve the Header. + * + * @return The Header attribute associate with this {@link Node} + */ + public String getHeader() { + return this.header; + } + + /** + * Retrieve the Description. + * + * @return The Description attribute associate with this {@link Node} + */ + public String getDescription() { + return this.description; + } + + String getAuthServiceId() { + return this.authServiceId; + } } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeInterceptorHandler.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeInterceptorHandler.java index c5600e43..bcb9b41e 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeInterceptorHandler.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeInterceptorHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -9,6 +9,8 @@ import android.content.Context; +import androidx.annotation.NonNull; + import java.util.List; /** @@ -22,7 +24,7 @@ class NodeInterceptorHandler extends InterceptorHandler implements NodeListener< } @Override - public void onCallbackReceived(Node node) { + public void onCallbackReceived(@NonNull Node node) { ((NodeListener)getListener()).onCallbackReceived(node); } @@ -32,9 +34,7 @@ public void onSuccess(SSOToken result) { } @Override - public void onException(Exception e) { + public void onException(@NonNull Exception e) { getListener().onException(e); } - - } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeListener.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeListener.java deleted file mode 100644 index 21a679d5..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeListener.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) 2019 - 2021 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth; - -import org.forgerock.android.auth.callback.Callback; -import org.forgerock.android.auth.callback.CallbackFactory; -import org.forgerock.android.auth.callback.DerivableCallback; -import org.forgerock.android.auth.callback.MetadataCallback; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.ArrayList; -import java.util.List; - -import javax.security.auth.callback.UnsupportedCallbackException; - -import static org.forgerock.android.auth.Node.AUTH_ID; -import static org.forgerock.android.auth.Node.DESCRIPTION; -import static org.forgerock.android.auth.Node.HEADER; -import static org.forgerock.android.auth.Node.STAGE; - -/** - * Interface for an object that listens to changes resulting from a {@link AuthService}. - */ -public interface NodeListener extends FRListener { - - - String TAG = NodeListener.class.getSimpleName(); - - /** - * Notify the listener that the {@link AuthService} has been started and moved to the first node. - * - * @param node The first Node - */ - void onCallbackReceived(Node node); - - /** - * Transform the response from AM Intelligent Tree to Node Object, after the transformation - * {@link #onCallbackReceived(Node)} will be invoked with the returned {@link Node}. - * - * @param authServiceId Unique Auth Service Id - * @param response The JSON Response from AM Intelligent Tree - * @return The Node Object - * @throws Exception Any error during the transformation - */ - default Node onCallbackReceived(String authServiceId, JSONObject response) throws Exception { - - List callbacks = parseCallback(response.getJSONArray("callbacks")); - - return new Node(response.getString(AUTH_ID) - , response.optString(STAGE, getStage(callbacks)) - , response.optString(HEADER, null) - , response.optString(DESCRIPTION, null) - , authServiceId, - callbacks); - } - - /** - * Parse the JSON Array callback response from AM, and transform to {@link Callback} instances. - * - * @param jsonArray The JSON Array callback response from AM - * @return A List of {@link Callback} Object - * @throws Exception Any error during the transformation - */ - default List parseCallback(JSONArray jsonArray) throws Exception { - List callbacks = new ArrayList<>(); - for (int i = 0; i < jsonArray.length(); i++) { - JSONObject cb = jsonArray.getJSONObject(i); - String type = cb.getString("type"); - // Return the Callback Class which represent the Callback from AM - Class clazz = CallbackFactory.getInstance().getCallbacks().get(type); - if (clazz == null) { - //When Callback is not registered to the SDK - throw new UnsupportedCallbackException(null, "Callback Type Not Supported: " + cb.getString("type")); - } - Callback callback = clazz.getConstructor(JSONObject.class, int.class).newInstance(cb, i); - if (callback instanceof DerivableCallback) { - Class derivedClass = ((DerivableCallback) callback).getDerivedCallback(); - if (derivedClass != null) { - callback = derivedClass.getConstructor(JSONObject.class, int.class).newInstance(cb, i); - } else { - Logger.debug(TAG, "Derive class not found."); - } - } - callbacks.add(callback); - } - return callbacks; - } - - /** - * Workaround stage property for AM version < 7.0. - * https://github.com/jaredjensen/forgerock-sdk-blog/blob/master/auth_tree_stage.md - * - * @param callbacks Callback from Intelligent Tree - * @return stage or null if not found. - */ - default String getStage(List callbacks) { - for (Callback callback : callbacks) { - if (callback.getClass().equals(MetadataCallback.class)) { - try { - return ((MetadataCallback) callback).getValue().getString("stage"); - } catch (JSONException e) { - //ignore and continue to find the next metadata callback. - } - } - } - return null; - } -} - diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeListener.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeListener.kt new file mode 100644 index 00000000..016629e3 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/NodeListener.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth + +import org.forgerock.android.auth.Logger.Companion.debug +import org.forgerock.android.auth.callback.Callback +import org.forgerock.android.auth.callback.CallbackFactory +import org.forgerock.android.auth.callback.DerivableCallback +import org.forgerock.android.auth.callback.MetadataCallback +import org.forgerock.android.auth.callback.NodeAware +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import javax.security.auth.callback.UnsupportedCallbackException + +/** + * Interface for an object that listens to changes resulting from a [AuthService]. + */ +interface NodeListener : FRListener { + /** + * Notify the listener that the [AuthService] has been started and moved to the first node. + * + * @param node The first Node + */ + fun onCallbackReceived(node: Node) + + /** + * Transform the response from AM Intelligent Tree to Node Object, after the transformation + * [.onCallbackReceived] will be invoked with the returned [Node]. + * + * @param authServiceId Unique Auth Service Id + * @param response The JSON Response from AM Intelligent Tree + * @return The Node Object + * @throws Exception Any error during the transformation + */ + @Throws(Exception::class) + fun onCallbackReceived(authServiceId: String, + response: JSONObject): Node { + val callbacks = + parseCallback(response.getJSONArray("callbacks")) + val node = Node(response.getString(Node.AUTH_ID), + response.optString(Node.STAGE, getStage(callbacks)), + response.optString(Node.HEADER, null), + response.optString(Node.DESCRIPTION, null), + authServiceId, + callbacks) + callbacks.forEach { + if (it is NodeAware) { + it.setNode(node) + } + } + return node + } + + /** + * Parse the JSON Array callback response from AM, and transform to [Callback] instances. + * + * @param jsonArray The JSON Array callback response from AM + * @return A List of [Callback] Object + * @throws Exception Any error during the transformation + */ + @Throws(Exception::class) + fun parseCallback(jsonArray: JSONArray): List { + val callbacks: MutableList = ArrayList() + for (i in 0 until jsonArray.length()) { + val cb = jsonArray.getJSONObject(i) + val type = cb.getString("type") + // Return the Callback Class which represent the Callback from AM + val clazz = CallbackFactory.getInstance().callbacks[type] + ?: //When Callback is not registered to the SDK + throw UnsupportedCallbackException(null, + "Callback Type Not Supported: " + cb.getString("type")) + var callback = + clazz.getConstructor(JSONObject::class.java, Int::class.javaPrimitiveType) + .newInstance(cb, i) + if (callback is DerivableCallback) { + val derivedClass = (callback as DerivableCallback).derivedCallback + if (derivedClass != null) { + callback = derivedClass.getConstructor(JSONObject::class.java, + Int::class.javaPrimitiveType).newInstance(cb, i) + } else { + debug(TAG, "Derive class not found.") + } + } + callbacks.add(callback) + } + return callbacks + } + + /** + * Workaround stage property for AM version < 7.0. + * https://github.com/jaredjensen/forgerock-sdk-blog/blob/master/auth_tree_stage.md + * + * @param callbacks Callback from Intelligent Tree + * @return stage or null if not found. + */ + fun getStage(callbacks: List): String? { + for (callback in callbacks) { + if (callback.javaClass == MetadataCallback::class.java) { + try { + return (callback as MetadataCallback).value.getString("stage") + } catch (e: JSONException) { + //ignore and continue to find the next metadata callback. + } + } + } + return null + } + + companion object { + @JvmField + val TAG = NodeListener::class.java.simpleName + } +} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/SecureCookieJar.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/SecureCookieJar.java deleted file mode 100644 index 3cd9b2da..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/SecureCookieJar.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright (c) 2019 - 2021 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth; - -import android.content.Context; - -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Set; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -import lombok.Builder; -import okhttp3.Cookie; -import okhttp3.CookieJar; -import okhttp3.HttpUrl; - -public class SecureCookieJar implements CookieJar { - - private SingleSignOnManager singleSignOnManager; - private AtomicReference> cacheRef = new AtomicReference<>(); - private final long cacheIntervalMillis; - private static final ScheduledExecutorService worker = - Executors.newSingleThreadScheduledExecutor(); - private final CookieMarshaller cookieMarshaller = new CookieMarshaller(); - - @Builder - public SecureCookieJar(Context context, SingleSignOnManager singleSignOnManager, Long cacheIntervalMillis) { - this.singleSignOnManager = singleSignOnManager == null ? - Config.getInstance().getSingleSignOnManager() : singleSignOnManager; - this.cacheIntervalMillis = cacheIntervalMillis == null ? - context.getResources().getInteger(R.integer.forgerock_cookie_cache) * 1000 : cacheIntervalMillis; - } - - @NotNull - @Override - public synchronized List loadForRequest(@NotNull HttpUrl httpUrl) { - - Set cookies = cacheRef.get(); - if (cookies == null) { - cookies = new HashSet<>(); - Collection storedCookies = singleSignOnManager.getCookies(); - if (!storedCookies.isEmpty()) { - Set updatedCookies = new HashSet<>(storedCookies); - Iterator iterator = updatedCookies.iterator(); - while (iterator.hasNext()) { - Cookie cookie = cookieMarshaller.unmarshal(iterator.next()); - if (cookie != null) { - if (!isExpired(cookie)) { - cookies.add(cookie); - } else { - //Remove expired cookies - iterator.remove(); - } - } else { - //Failed to parse it - iterator.remove(); - } - } - - // Some cookies are expired, or failed to parse, remove it - if (storedCookies.size() != updatedCookies.size()) { - cache(cookies); - persist(updatedCookies); - } - } - } - - return filter(httpUrl, cookies); - } - - @Override - public synchronized void saveFromResponse(@NotNull HttpUrl httpUrl, @NotNull List list) { - - Set cookies = new HashSet<>(); - for (String c : singleSignOnManager.getCookies()) { - Cookie cookie = cookieMarshaller.unmarshal(c); - //Remove the same stored cookies - if (cookie != null && !contains(cookie, list)) { - cookies.add(cookie); - } - } - - for (Cookie cookie : list) { - if (!isExpired(cookie)) { - cookies.add(cookie); - } - } - - cache(cookies); - - Set updatedCookies = new HashSet<>(); - for (Cookie cookie : cookies) { - updatedCookies.add(cookieMarshaller.marshal(cookie)); - } - - persist(updatedCookies); - } - - private boolean contains(Cookie input, Collection cookies) { - for (Cookie cookie : cookies) { - if (cookie.name().equals(input.name()) && - cookie.domain().equals(input.domain()) && - cookie.path().equals(input.path())) { - return true; - } - } - return false; - } - - private List filter(HttpUrl httpUrl, Set cookies) { - List result = new ArrayList<>(); - for (Cookie cookie : cookies) { - if (!isExpired(cookie) && - cookie.matches(httpUrl)) { - result.add(cookie); - } - } - return result; - } - - private boolean isExpired(Cookie cookie) { - return cookie.expiresAt() < System.currentTimeMillis(); - } - - private void cache(Set cookies) { - if (cacheIntervalMillis > 0) { - cacheRef.set(cookies); - worker.schedule(() -> cacheRef.set(null), cacheIntervalMillis, TimeUnit.MILLISECONDS); - } - } - - private void persist(Collection cookies) { - singleSignOnManager.persist(cookies); - FRLifecycle.dispatchCookiesUpdated(cookies); - } - -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/SecureCookieJar.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/SecureCookieJar.kt new file mode 100644 index 00000000..45f6852b --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/SecureCookieJar.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth + +import android.content.Context +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.internal.toImmutableList +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class SecureCookieJar(context: Context, + singleSignOnManager: SingleSignOnManager?, + cacheIntervalMillis: Long?) : CookieJar, OkHttpCookieInterceptor { + + private val singleSignOnManager: SingleSignOnManager + private val cacheRef = AtomicReference?>() + private val cacheIntervalMillis: Long + private val cookieMarshaller = CookieMarshaller() + + init { + this.singleSignOnManager = singleSignOnManager ?: Config.getInstance().singleSignOnManager + this.cacheIntervalMillis = cacheIntervalMillis + ?: (context.resources.getInteger(R.integer.forgerock_cookie_cache) * 1000).toLong() + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + var cookies = cacheRef.get() + if (cookies == null) { + cookies = HashSet() + val storedCookies = singleSignOnManager.cookies + if (!storedCookies.isEmpty()) { + val updatedCookies: MutableSet = HashSet(storedCookies) + val iterator = updatedCookies.iterator() + while (iterator.hasNext()) { + val cookie = cookieMarshaller.unmarshal(iterator.next()) + if (cookie != null) { + if (!isExpired(cookie)) { + cookies.add(cookie) + } else { + //Remove expired cookies + iterator.remove() + } + } else { + //Failed to parse it + iterator.remove() + } + } + + // Some cookies are expired, or failed to parse, remove it + if (storedCookies.size != updatedCookies.size) { + cache(cookies) + persist(updatedCookies) + } + } + } + return filter(url, cookies) + } + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val cookiesContainer: MutableSet = HashSet() + for (c in singleSignOnManager.cookies) { + val cookie = cookieMarshaller.unmarshal(c) + //Remove the same stored cookies + if (cookie != null && !contains(cookie, cookies)) { + cookiesContainer.add(cookie) + } + } + for (cookie in cookies) { + if (!isExpired(cookie)) { + cookiesContainer.add(cookie) + } + } + cache(cookiesContainer) + val updatedCookies: MutableSet = HashSet() + for (cookie in cookiesContainer) { + updatedCookies.add(cookieMarshaller.marshal(cookie)) + } + persist(updatedCookies) + } + + private fun contains(input: Cookie, cookies: Collection): Boolean { + for (cookie in cookies) { + if (cookie.name == input.name && cookie.domain == input.domain && cookie.path == input.path) { + return true + } + } + return false + } + + private fun filter(url: HttpUrl, cookies: Set): List { + val result = mutableListOf() + cookies.filter { !isExpired(it) } + .filter { it.matches(url) } + .toCollection(result) + + return intercept(result.toImmutableList()) + } + + private fun isExpired(cookie: Cookie): Boolean { + return cookie.expiresAt < System.currentTimeMillis() + } + + private fun cache(cookies: MutableSet) { + if (cacheIntervalMillis > 0) { + cacheRef.set(cookies) + worker.schedule({ cacheRef.set(null) }, cacheIntervalMillis, TimeUnit.MILLISECONDS) + } + } + + private fun persist(cookies: Collection) { + singleSignOnManager.persist(cookies) + FRLifecycle.dispatchCookiesUpdated(cookies) + } + + class SecureCookieJarBuilder internal constructor() { + private lateinit var context: Context + private var singleSignOnManager: SingleSignOnManager? = null + private var cacheIntervalMillis: Long? = null + fun context(context: Context): SecureCookieJarBuilder { + this.context = context + return this + } + + fun singleSignOnManager(singleSignOnManager: SingleSignOnManager?): SecureCookieJarBuilder { + this.singleSignOnManager = singleSignOnManager + return this + } + + fun cacheIntervalMillis(cacheIntervalMillis: Long?): SecureCookieJarBuilder { + this.cacheIntervalMillis = cacheIntervalMillis + return this + } + + fun build(): SecureCookieJar { + return SecureCookieJar(context, singleSignOnManager, cacheIntervalMillis) + } + + override fun toString(): String { + return "SecureCookieJar.SecureCookieJarBuilder(context=$context, " + + "singleSignOnManager=$singleSignOnManager, cacheIntervalMillis=$cacheIntervalMillis)" + } + } + + companion object { + private val worker = Executors.newSingleThreadScheduledExecutor() + + @JvmStatic + fun builder(): SecureCookieJarBuilder { + return SecureCookieJarBuilder() + } + } +} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/AppIntegrityCallback.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/AppIntegrityCallback.kt new file mode 100644 index 00000000..df20dcbb --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/AppIntegrityCallback.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth.callback + +import android.content.Context +import android.util.Base64 +import androidx.annotation.Keep +import androidx.annotation.OpenForTesting +import com.google.android.play.core.integrity.IntegrityManager +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.IntegrityTokenRequest +import com.google.android.play.core.integrity.StandardIntegrityManager +import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeout +import org.forgerock.android.auth.FRListener +import org.forgerock.android.auth.Listener +import org.forgerock.android.auth.Logger +import org.forgerock.android.auth.Node +import org.forgerock.android.auth.callback.RequestType.CLASSIC +import org.forgerock.android.auth.callback.RequestType.STANDARD +import org.json.JSONObject +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +private val TAG = AppIntegrityCallback::class.java.simpleName + +/** + * Callback to collect the device binding information + */ +open class AppIntegrityCallback : NodeAware, AbstractCallback { + + @Keep + @JvmOverloads + constructor(jsonObject: JSONObject, index: Int) : super(jsonObject, index) + + @Keep + @JvmOverloads + constructor() : super() + + @OpenForTesting + companion object { + val cache = ConcurrentHashMap() + } + + private lateinit var node: Node + + lateinit var requestType: RequestType + private set + + /** + * The projectNumber received from server + */ + lateinit var projectNumber: String + private set + + /** + * The nonce received from server + */ + lateinit var nonce: String + private set + + final override fun setAttribute(name: String, value: Any) = when (name) { + "requestType" -> requestType = RequestType.valueOf((value as String).uppercase()) + "projectNumber" -> projectNumber = value as String + "nonce" -> nonce = value as String + else -> {} + } + + override fun getType(): String { + return "AppIntegrityCallback" + } + + /** + * Input the Token to the server + * @param value The JWS value. + */ + fun setToken(value: String) { + super.setValue(value, 0) + } + + /** + * Input the Client Error to the server + * @param value DeviceBind ErrorType . + */ + fun setClientError(value: String) { + super.setValue(value, 1) + } + + /** + * Retrieve the timeout to retrieve an Integrity Token + * Default to 10 Seconds + */ + open fun getTimeout(): Duration { + return 10.toDuration(DurationUnit.SECONDS) + } + + + /** + * Request for Integrity Token from Google SDK + * + * @param context The Application Context + * @param listener The Listener to listen for the result + */ + open fun requestIntegrityToken(context: Context, + listener: FRListener) { + val scope = CoroutineScope(Dispatchers.Default) + scope.launch { + try { + requestIntegrityToken(context) + Listener.onSuccess(listener, null) + } catch (e: Exception) { + Listener.onException(listener, e) + } + } + } + + /** + * Bind the device. + * + * @param context The Application Context + */ + open suspend fun requestIntegrityToken(context: Context) { + + try { + withTimeout(getTimeout()) { + when (requestType) { + CLASSIC -> { + val integrityManager = getIntegrityManager(context) + val builder = IntegrityTokenRequest.builder() + builder.setCloudProjectNumber(projectNumber.toLong()) + builder.setNonce(hashWithNonce(getAuthId())) + setToken(integrityManager.requestIntegrityToken(builder.build()).await() + .token()) + } + + STANDARD -> { + setToken(getStandardIntegrityTokenProvider(context) + .request(StandardIntegrityTokenRequest + .builder().setRequestHash(hash(getAuthId())).build()) + .await().token()) + } + } + } + } catch (e: Exception) { + Logger.error(TAG, t = e, message = e.message) + setClientError("ClientDeviceErrors") + //This can be IntegrityServiceException + throw e + } + } + + /** + * Clear the cache to store the [StandardIntegrityTokenProvider] + */ + open fun clearCache() { + cache.clear() + } + + @OpenForTesting + open fun getIntegrityManager(context: Context): IntegrityManager { + return IntegrityManagerFactory.create(context) + } + + /** + * For Standard API + */ + @OpenForTesting + open fun getStandardIntegrityManager(context: Context): StandardIntegrityManager { + return IntegrityManagerFactory.createStandard(context) + } + + open suspend fun getStandardIntegrityTokenProvider(context: Context): + StandardIntegrityTokenProvider { + return cache.getOrElse(projectNumber) { + val integrityManager = getStandardIntegrityManager(context) + val request = PrepareIntegrityTokenRequest.builder() + .setCloudProjectNumber(projectNumber.toLong()).build() + val provider = integrityManager.prepareIntegrityToken(request).await() + cache[projectNumber] = provider + return provider + } + } + + @OpenForTesting + open fun getAuthId(): ByteArray { + return node.authId.toByteArray() + } + + private fun hashWithNonce(input: ByteArray): String { + val flags = Base64.NO_WRAP or Base64.URL_SAFE + val bytes = Base64.decode(nonce, flags) + val result = input.plus(bytes) + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(result) + return Base64.encodeToString(messageDigest.digest(), flags); + } + + private fun hash(input: ByteArray): String { + val flags = Base64.NO_WRAP or Base64.URL_SAFE + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(input) + return Base64.encodeToString(messageDigest.digest(), flags); + } + + + override fun setNode(node: Node) { + this.node = node + } + +} + +enum class RequestType { + CLASSIC, + STANDARD; +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/CallbackFactory.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/CallbackFactory.java index bfc6489b..7ca0a0d3 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/CallbackFactory.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/CallbackFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 - 2022 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -12,8 +12,6 @@ import java.util.HashMap; import java.util.Map; -import lombok.Getter; - /** * Factory to manage supported {@link Callback} */ @@ -22,7 +20,6 @@ public class CallbackFactory { private static final String TAG = CallbackFactory.class.getSimpleName(); private static final CallbackFactory INSTANCE = new CallbackFactory(); - @Getter private Map> callbacks = new HashMap<>(); private CallbackFactory() { @@ -51,6 +48,7 @@ private CallbackFactory() { register(IdPCallback.class); register(DeviceBindingCallback.class); register(DeviceSigningVerifierCallback.class); + register(AppIntegrityCallback.class); } /** @@ -79,4 +77,7 @@ public String getType(Class callback) throws InstantiationEx return callback.newInstance().getType(); } + public Map> getCallbacks() { + return this.callbacks; + } } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/DeviceBindingCallback.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/DeviceBindingCallback.kt index e87c1086..0df9c831 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/DeviceBindingCallback.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/DeviceBindingCallback.kt @@ -204,11 +204,9 @@ open class DeviceBindingCallback : AbstractCallback, Binding { * Helper method to execute binding , signing, show biometric prompt. * * @param context The Application Context - * @param listener The Listener to listen for the result * @param deviceAuthenticator Interface to find the Authentication Type * @param deviceBindingRepository Persist the values in encrypted shared preference */ - @JvmOverloads internal suspend fun execute(context: Context, deviceAuthenticator: DeviceAuthenticator = getDeviceAuthenticator( deviceBindingAuthenticationType), @@ -245,6 +243,7 @@ open class DeviceBindingCallback : AbstractCallback, Binding { deviceBindingRepository.persist(it) val jws = deviceAuthenticator.sign(context, kp, + status.signature, it.kid, userId, challenge, diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallback.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallback.kt index 0f133879..8148bd98 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallback.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallback.kt @@ -19,6 +19,7 @@ import org.forgerock.android.auth.devicebind.DeviceAuthenticator import org.forgerock.android.auth.devicebind.DeviceBindingErrorStatus import org.forgerock.android.auth.devicebind.DeviceBindingErrorStatus.ClientNotRegistered import org.forgerock.android.auth.devicebind.DeviceBindingErrorStatus.Unsupported +import org.forgerock.android.auth.devicebind.DeviceBindingErrorStatus.InvalidCustomClaims import org.forgerock.android.auth.devicebind.DeviceBindingException import org.forgerock.android.auth.devicebind.NoKeysFound import org.forgerock.android.auth.devicebind.Prompt @@ -109,8 +110,8 @@ open class DeviceSigningVerifierCallback : AbstractCallback, Binding { * Input the Client Error to the server * @param value DeviceSign ErrorType . */ - override fun setClientError(value: String?) { - super.setValue(value, 1) + override fun setClientError(clientError: String?) { + super.setValue(clientError, 1) } /** @@ -118,14 +119,17 @@ open class DeviceSigningVerifierCallback : AbstractCallback, Binding { * * @param context The Application Context * @param userKeySelector Collect user key, if not provided [DefaultUserKeySelector] will be used + * @param customClaims A map of custom claims to be added to the jws payload * @param deviceAuthenticator A function to return a [DeviceAuthenticator], [deviceAuthenticatorIdentifier] will be used if not provided */ open suspend fun sign(context: Context, + customClaims: Map = emptyMap(), userKeySelector: UserKeySelector = DefaultUserKeySelector(), deviceAuthenticator: (type: DeviceBindingAuthenticationType) -> DeviceAuthenticator = deviceAuthenticatorIdentifier) { execute(context, userKeySelector = userKeySelector, - deviceAuthenticator = deviceAuthenticator) + deviceAuthenticator = deviceAuthenticator, + customClaims = customClaims) } @@ -134,18 +138,20 @@ open class DeviceSigningVerifierCallback : AbstractCallback, Binding { * * @param context The Application Context * @param userKeySelector Collect user key, if not provided [DefaultUserKeySelector] will be used + * @param customClaims A map of custom claims to be added to the jws payload * @param deviceAuthenticator A function to return a [DeviceAuthenticator], [deviceAuthenticatorIdentifier] will be used if not provided * @param listener The Listener to listen for the result */ @JvmOverloads open fun sign(context: Context, + customClaims: Map = emptyMap(), userKeySelector: UserKeySelector = DefaultUserKeySelector(), deviceAuthenticator: (type: DeviceBindingAuthenticationType) -> DeviceAuthenticator = deviceAuthenticatorIdentifier, listener: FRListener) { val scope = CoroutineScope(Dispatchers.Default) scope.launch { try { - sign(context, userKeySelector, deviceAuthenticator) + sign(context, customClaims, userKeySelector, deviceAuthenticator) Listener.onSuccess(listener, null) } catch (e: Exception) { Listener.onException(listener, e) @@ -159,23 +165,33 @@ open class DeviceSigningVerifierCallback : AbstractCallback, Binding { * @param context Application Context * @param userKey User Information * @param deviceAuthenticator A function to return a [DeviceAuthenticator], [getDeviceAuthenticator] will be used if not provided + * @param customClaims A map of custom claims to be added to the jws payload */ protected open suspend fun authenticate(context: Context, userKey: UserKey, - deviceAuthenticator: DeviceAuthenticator) { + deviceAuthenticator: DeviceAuthenticator, + customClaims: Map = emptyMap()) { deviceAuthenticator.initialize(userKey.userId, Prompt(title, subtitle, description)) if (deviceAuthenticator.isSupported(context).not()) { handleException(DeviceBindingException(Unsupported())) } + + if (deviceAuthenticator.validateCustomClaims(customClaims).not()) { + handleException(DeviceBindingException(InvalidCustomClaims())) + return + } + when (val status = deviceAuthenticator.authenticate(context)) { is Success -> { val jws = deviceAuthenticator.sign(context, userKey, status.privateKey, + status.signature, challenge, - getExpiration(timeout)) + getExpiration(timeout), + customClaims) setJws(jws) } is DeviceBindingErrorStatus -> { @@ -186,11 +202,11 @@ open class DeviceSigningVerifierCallback : AbstractCallback, Binding { } - @JvmOverloads internal suspend fun execute(context: Context, userKeyService: UserKeyService = UserDeviceKeyService(context), userKeySelector: UserKeySelector = DefaultUserKeySelector(), - deviceAuthenticator: (type: DeviceBindingAuthenticationType) -> DeviceAuthenticator) { + deviceAuthenticator: (type: DeviceBindingAuthenticationType) -> DeviceAuthenticator, + customClaims: Map = emptyMap()) { try { withTimeout(getDuration(timeout)) { @@ -198,11 +214,12 @@ open class DeviceSigningVerifierCallback : AbstractCallback, Binding { is NoKeysFound -> handleException(DeviceBindingException(ClientNotRegistered())) is SingleKeyFound -> authenticate(context, status.key, - deviceAuthenticator(status.key.authType)) + deviceAuthenticator(status.key.authType), + customClaims) else -> { val userKey = userKeySelector.selectUserKey(UserKeys(userKeyService.getAll())) - authenticate(context, userKey, deviceAuthenticator(userKey.authType)) + authenticate(context, userKey, deviceAuthenticator(userKey.authType), customClaims) } } } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/NodeAware.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/NodeAware.kt new file mode 100644 index 00000000..92642856 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/NodeAware.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.callback + +import org.forgerock.android.auth.Node + +/** + * For [Callback] which need to have awareness of the parent [Node] + */ +interface NodeAware { + + /** + * Inject the [Node] object + */ + fun setNode(node: Node) +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/ReCaptchaCallback.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/ReCaptchaCallback.java index 16483c85..e9fda7e6 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/ReCaptchaCallback.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/ReCaptchaCallback.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -52,6 +52,15 @@ protected void setAttribute(String name, Object value) { } } + /** + * Set the Value for the ReCAPTCHA + * + * @param token The Token received from the captcha server + */ + public void setValue(String token) { + super.setValue(token); + } + /** * Proceed to trigger the ReCAPTCHA * @@ -68,7 +77,9 @@ public void proceed(Context context, FRListener listener) { } Listener.onSuccess(listener, null); }) - .addOnFailureListener(e -> Listener.onException(listener, e)); + .addOnFailureListener(e -> + Listener.onException(listener, e) + ); } @Override diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricAndDeviceCredential.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricAndDeviceCredential.kt new file mode 100644 index 00000000..1d7fc8b5 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricAndDeviceCredential.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.devicebind + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.security.keystore.KeyProperties +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import org.forgerock.android.auth.callback.Attestation +import org.forgerock.android.auth.callback.DeviceBindingAuthenticationType +import java.security.PrivateKey +import java.security.interfaces.RSAPublicKey + +/** + * Settings for all the biometric authentication and device credential is configured + */ +open class BiometricAndDeviceCredential : BiometricAuthenticator() { + override fun authenticate(authenticationCallback: BiometricPrompt.AuthenticationCallback, + privateKey: PrivateKey) { + biometricInterface.authenticate(authenticationCallback) + } + + /** + * generate the public and private keypair + */ + @SuppressLint("NewApi") + override suspend fun generateKeys(context: Context, attestation: Attestation): KeyPair { + val builder = cryptoKey.keyBuilder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setAttestationChallenge(attestation.challenge) + } + + //We use time-base key because we allow device credential as fallback + //Device credential is not consider biometric strong + if (isApi30OrAbove) { + builder.setUserAuthenticationParameters(cryptoKey.timeout, + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) + } else { + builder.setUserAuthenticationValidityDurationSeconds(cryptoKey.timeout) + } + + builder.setUserAuthenticationRequired(true) + val key = cryptoKey.createKeyPair(builder.build()) + return KeyPair(key.public as RSAPublicKey, key.private, cryptoKey.keyAlias) + } + + /** + * check biometric is supported + */ + override fun isSupported(context: Context, attestation: Attestation): Boolean { + return super.isSupported(context, attestation) && + biometricInterface.isSupported( + BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL, + BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL) + } + + + final override fun type(): DeviceBindingAuthenticationType = + DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK + + +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricAuthenticator.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricAuthenticator.kt new file mode 100644 index 00000000..c40a7741 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricAuthenticator.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.devicebind + +import android.content.Context +import android.os.Build +import androidx.annotation.VisibleForTesting +import androidx.biometric.BiometricPrompt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.forgerock.android.auth.CryptoKey +import java.security.PrivateKey +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +abstract class BiometricAuthenticator : CryptoAware, DeviceAuthenticator { + + @VisibleForTesting + internal var isApi30OrAbove = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + internal lateinit var cryptoKey: CryptoKey + internal lateinit var biometricInterface: BiometricHandler + + final override fun setKey(cryptoKey: CryptoKey) { + this.cryptoKey = cryptoKey + } + + fun setBiometricHandler(biometricHandler: BiometricHandler) { + this.biometricInterface = biometricHandler + } + + override fun deleteKeys(context: Context) { + cryptoKey.deleteKeys() + } + + /** + * Display biometric prompt for authentication type + * @param context Application Context + * @return statusResult Listener for receiving Biometric changes + */ + override suspend fun authenticate(context: Context): DeviceBindingStatus = + withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + //The keys may be removed due to pin change + val privateKey = cryptoKey.getPrivateKey() + if (privateKey == null) { + continuation.resume(DeviceBindingErrorStatus.ClientNotRegistered()) + } else { + val listener = object : BiometricPrompt.AuthenticationCallback() { + + override fun onAuthenticationError(errorCode: Int, + errString: CharSequence) { + when (errorCode) { + BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON -> continuation.resume( + DeviceBindingErrorStatus.Abort(errString.toString(), + code = errorCode)) + + BiometricPrompt.ERROR_TIMEOUT -> continuation.resume( + DeviceBindingErrorStatus.Timeout( + errString.toString(), + code = errorCode)) + + BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, BiometricPrompt.ERROR_HW_NOT_PRESENT -> continuation.resume( + DeviceBindingErrorStatus.Unsupported(errString.toString(), + code = errorCode)) + + BiometricPrompt.ERROR_VENDOR -> continuation.resume( + DeviceBindingErrorStatus.Unsupported( + errString.toString(), + code = errorCode)) + + BiometricPrompt.ERROR_LOCKOUT_PERMANENT, BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_NO_SPACE, BiometricPrompt.ERROR_HW_UNAVAILABLE, BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> continuation.resume( + DeviceBindingErrorStatus.UnAuthorize(errString.toString(), + code = errorCode)) + + else -> { + continuation.resume(DeviceBindingErrorStatus.Unknown(errString.toString(), + code = errorCode)) + } + } + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + result.cryptoObject?.signature?.let { + continuation.resume(Success(privateKey, it)) + return + } + continuation.resume(Success(privateKey)) + } + + override fun onAuthenticationFailed() { + //Ignore with wrong fingerprint + } + } + + authenticate(listener, privateKey) + } + } + } + + /** + * Launch the Biometric Prompt. + * @param authenticationCallback [BiometricPrompt.AuthenticationCallback] to handle the result. + * @param privateKey The private key to unlock + */ + abstract fun authenticate(authenticationCallback: BiometricPrompt.AuthenticationCallback, + privateKey: PrivateKey) +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricBindingHandler.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricBindingHandler.kt index b061cf8a..42489cdc 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricBindingHandler.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricBindingHandler.kt @@ -8,6 +8,7 @@ package org.forgerock.android.auth.devicebind import androidx.biometric.BiometricManager.Authenticators.* import androidx.biometric.BiometricPrompt.AuthenticationCallback +import androidx.biometric.BiometricPrompt.CryptoObject import androidx.fragment.app.FragmentActivity import org.forgerock.android.auth.InitProvider import org.forgerock.android.auth.biometric.AuthenticatorType @@ -30,11 +31,13 @@ interface BiometricHandler { fun isSupported(strongAuthenticators: Int = BIOMETRIC_STRONG, weakAuthenticators: Int = BIOMETRIC_WEAK): Boolean + fun isSupportedBiometricStrong(): Boolean + /** * display biometric prompt for Biometric and device credential * @param authenticationCallback Result of biometric action in callback */ - fun authenticate(authenticationCallback: AuthenticationCallback) + fun authenticate(authenticationCallback: AuthenticationCallback, cryptoObject: CryptoObject? = null) } @@ -69,11 +72,11 @@ internal class BiometricBindingHandler(titleValue: String, * display biometric prompt for Biometric and device credential * @param authenticationCallback Result of biometric action in callback */ - override fun authenticate(authenticationCallback: AuthenticationCallback) { + override fun authenticate(authenticationCallback: AuthenticationCallback, cryptoObject: CryptoObject?) { biometricListener = authenticationCallback biometricAuth?.biometricAuthListener = authenticationCallback - biometricAuth?.authenticate() + biometricAuth?.authenticate(cryptoObject) } /** @@ -86,17 +89,14 @@ internal class BiometricBindingHandler(titleValue: String, when { // API 29 and above, check the support for STRONG type this.hasBiometricCapability(strongAuthenticators) -> { - this.authenticatorType = AuthenticatorType.STRONG return true } // API 29 and above, use BiometricPrompt for WEAK type this.hasBiometricCapability(weakAuthenticators) -> { - this.authenticatorType = AuthenticatorType.WEAK return true } // API 23 - 28, check enrollment with FingerprintManager once BiometricPrompt might not work this.hasEnrolledWithFingerPrint() -> { - this.authenticatorType = AuthenticatorType.WEAK return true } // API 23 - 28, using keyguard manager to verify and Display Device credential screen to enter pin @@ -107,4 +107,7 @@ internal class BiometricBindingHandler(titleValue: String, } return false } + + override fun isSupportedBiometricStrong() = + biometricAuth?.hasBiometricCapability(BIOMETRIC_STRONG) ?: false } \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricOnly.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricOnly.kt new file mode 100644 index 00000000..70d585c2 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/BiometricOnly.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.devicebind + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.security.keystore.KeyProperties +import androidx.annotation.VisibleForTesting +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.crypto.impl.RSASSAProvider +import org.forgerock.android.auth.Logger +import org.forgerock.android.auth.callback.Attestation +import org.forgerock.android.auth.callback.DeviceBindingAuthenticationType +import java.security.PrivateKey +import java.security.Signature +import java.security.interfaces.RSAPublicKey + +private val TAG = BiometricOnly::class.java.simpleName + +/** + * Settings for all the biometric authentication is configured + */ +open class BiometricOnly : BiometricAuthenticator() { + + @VisibleForTesting + fun getSignature(privateKey: PrivateKey): Signature { + return object : RSASSAProvider() { + }.signature(JWSAlgorithm.parse(getAlgorithm()), privateKey) + } + + override fun authenticate(authenticationCallback: BiometricPrompt.AuthenticationCallback, + privateKey: PrivateKey) { + try { + biometricInterface.authenticate(authenticationCallback, + BiometricPrompt.CryptoObject(getSignature(privateKey))) + } catch (e: Exception) { + //Failed because the key was generated with + //KeyGenParameterSpec.Builder.setUserAuthenticationParameters + Logger.warn(TAG, "Fallback to time-based key", e) + biometricInterface.authenticate(authenticationCallback) + } + } + + /** + * generate the public and private keypair + */ + @SuppressLint("NewApi") + override suspend fun generateKeys(context: Context, attestation: Attestation): KeyPair { + val builder = cryptoKey.keyBuilder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setAttestationChallenge(attestation.challenge) + } + + if (biometricInterface.isSupportedBiometricStrong().not()) { + if (isApi30OrAbove) { + builder.setUserAuthenticationParameters(cryptoKey.timeout, + KeyProperties.AUTH_BIOMETRIC_STRONG) + } else { + builder.setUserAuthenticationValidityDurationSeconds(cryptoKey.timeout) + } + } + + builder.setUserAuthenticationRequired(true) + val key = cryptoKey.createKeyPair(builder.build()) + return KeyPair(key.public as RSAPublicKey, key.private, cryptoKey.keyAlias) + } + + + /** + * check biometric is supported + */ + override fun isSupported(context: Context, attestation: Attestation): Boolean { + return super.isSupported(context, attestation) && + biometricInterface.isSupported(BiometricManager.Authenticators.BIOMETRIC_STRONG, + BiometricManager.Authenticators.BIOMETRIC_WEAK) + } + + final override fun type(): DeviceBindingAuthenticationType = + DeviceBindingAuthenticationType.BIOMETRIC_ONLY + +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/DeviceBindAuthenticators.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/DeviceBindAuthenticators.kt index 5bd07865..7e79386b 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/DeviceBindAuthenticators.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/DeviceBindAuthenticators.kt @@ -7,49 +7,27 @@ package org.forgerock.android.auth.devicebind -import android.annotation.SuppressLint import android.content.Context import android.os.Build import android.os.Parcelable -import android.security.keystore.KeyProperties -import androidx.annotation.VisibleForTesting -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK -import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL -import androidx.biometric.BiometricPrompt -import androidx.biometric.BiometricPrompt.AuthenticationCallback -import androidx.biometric.BiometricPrompt.ERROR_CANCELED -import androidx.biometric.BiometricPrompt.ERROR_HW_NOT_PRESENT -import androidx.biometric.BiometricPrompt.ERROR_HW_UNAVAILABLE -import androidx.biometric.BiometricPrompt.ERROR_LOCKOUT -import androidx.biometric.BiometricPrompt.ERROR_LOCKOUT_PERMANENT -import androidx.biometric.BiometricPrompt.ERROR_NEGATIVE_BUTTON -import androidx.biometric.BiometricPrompt.ERROR_NO_BIOMETRICS -import androidx.biometric.BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -import androidx.biometric.BiometricPrompt.ERROR_NO_SPACE -import androidx.biometric.BiometricPrompt.ERROR_TIMEOUT -import androidx.biometric.BiometricPrompt.ERROR_UNABLE_TO_PROCESS -import androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED -import androidx.biometric.BiometricPrompt.ERROR_VENDOR -import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSAlgorithm.* import com.nimbusds.jose.JWSHeader import com.nimbusds.jose.crypto.RSASSASigner import com.nimbusds.jose.jwk.KeyUse import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jose.util.Base64 +import com.nimbusds.jwt.JWTClaimNames import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import org.forgerock.android.auth.CryptoKey +import org.forgerock.android.auth.Logger import org.forgerock.android.auth.callback.Attestation import org.forgerock.android.auth.callback.DeviceBindingAuthenticationType import java.security.PrivateKey +import java.security.Signature import java.security.interfaces.RSAPublicKey import java.util.* -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine private val TAG = DeviceAuthenticator::class.java.simpleName @@ -79,27 +57,41 @@ interface DeviceAuthenticator { //Do Nothing } + /** + * The JWS algorithm (alg) parameter. + * Header Parameter identifies the cryptographic algorithm + * used to secure the JWS. + */ + fun getAlgorithm(): String { + return "RS512" + } + /** * sign the challenge sent from the server and generate signed JWT * @param keyPair Public and private key * @param kid Generated kid from the Preference * @param userId userId received from server * @param challenge challenge received from server + * @param customClaims A map of custom claims to be added to the jws payload */ fun sign(context: Context, keyPair: KeyPair, + signature: Signature?, kid: String, userId: String, challenge: String, expiration: Date, attestation: Attestation = Attestation.None): String { - val builder = RSAKey.Builder(keyPair.publicKey).keyUse(KeyUse.SIGNATURE).keyID(kid) - .algorithm(JWSAlgorithm.RS512) + val builder = RSAKey.Builder(keyPair.publicKey) + .keyUse(KeyUse.SIGNATURE) + .keyID(kid) + .algorithm(parse(getAlgorithm())) if (attestation !is Attestation.None) { builder.x509CertChain(getCertificateChain(userId)) } val jwk = builder.build(); - val signedJWT = SignedJWT(JWSHeader.Builder(JWSAlgorithm.RS512).keyID(kid).jwk(jwk).build(), + val signedJWT = SignedJWT(JWSHeader.Builder(parse(getAlgorithm())) + .keyID(kid).jwk(jwk).build(), JWTClaimsSet.Builder().subject(userId) .issuer(context.packageName) .expirationTime(expiration) @@ -108,7 +100,15 @@ interface DeviceAuthenticator { .claim(PLATFORM, "android") .claim(ANDROID_VERSION, Build.VERSION.SDK_INT) .claim(CHALLENGE, challenge).build()) - signedJWT.sign(RSASSASigner(keyPair.privateKey)) + signature?.let { + //Using CryptoObject + Logger.info(TAG, "Use CryptObject signature for Signing") + signedJWT.sign(RSASASignatureSigner(signature)) + } ?: run { + Logger.info(TAG, "Use Private Key for Signing") + signedJWT.sign(RSASSASigner(keyPair.privateKey)) + } + return signedJWT.serialize() } @@ -123,21 +123,38 @@ interface DeviceAuthenticator { * sign the challenge sent from the server and generate signed JWT * @param userKey User Information * @param challenge challenge received from server + * @param customClaims A map of custom claims to be added to the jws payload */ fun sign(context: Context, userKey: UserKey, privateKey: PrivateKey, + signature: Signature?, challenge: String, - expiration: Date): String { + expiration: Date, + customClaims: Map = emptyMap()): String { + val claimsSet = JWTClaimsSet.Builder().subject(userKey.userId) + .issuer(context.packageName) + .claim(CHALLENGE, challenge) + .issueTime(getIssueTime()) + .notBeforeTime(getNotBeforeTime()) + .expirationTime(expiration) + customClaims.forEach { (key, value) -> + claimsSet.claim(key, value) + } + val signedJWT = + SignedJWT(JWSHeader.Builder(parse(getAlgorithm())) + .keyID(userKey.kid).build(), + claimsSet.build()) + //Use provided signature to sign if available otherwise use private key + signature?.let { + //Using CryptoObject + Logger.info(TAG, "Use CryptObject signature for Signing") + signedJWT.sign(RSASASignatureSigner(signature)) + } ?: run { + Logger.info(TAG, "Use Private Key for Signing") + signedJWT.sign(RSASSASigner(privateKey)) + } - val signedJWT = SignedJWT(JWSHeader.Builder(JWSAlgorithm.RS512).keyID(userKey.kid).build(), - JWTClaimsSet.Builder().subject(userKey.userId) - .issuer(context.packageName) - .claim(CHALLENGE, challenge) - .issueTime(getIssueTime()) - .notBeforeTime(getNotBeforeTime()) - .expirationTime(expiration).build()) - signedJWT.sign(RSASSASigner(privateKey)) return signedJWT.serialize() } @@ -172,6 +189,25 @@ interface DeviceAuthenticator { return Calendar.getInstance().time } + /** Validate custom claims + * @param customClaims: A map of custom claims to be validated + * @return Boolean value indicating whether the custom claims are valid or not + */ + fun validateCustomClaims(customClaims: Map): Boolean { + return customClaims.keys.intersect(registeredKeys).isEmpty() + } + + companion object { + val registeredKeys = listOf( + JWTClaimNames.SUBJECT, + JWTClaimNames.EXPIRATION_TIME, + JWTClaimNames.ISSUED_AT, + JWTClaimNames.NOT_BEFORE, + JWTClaimNames.ISSUER, + CHALLENGE + ) + } + } fun DeviceAuthenticator.initialize(userId: String, prompt: Prompt): DeviceAuthenticator { @@ -208,202 +244,3 @@ class Prompt(val title: String, val subtitle: String, var description: String) : data class KeyPair(val publicKey: RSAPublicKey, val privateKey: PrivateKey, var keyAlias: String) -abstract class BiometricAuthenticator : CryptoAware, DeviceAuthenticator { - - @VisibleForTesting - internal var isApi30OrAbove = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - internal lateinit var cryptoKey: CryptoKey - internal lateinit var biometricInterface: BiometricHandler - - final override fun setKey(cryptoKey: CryptoKey) { - this.cryptoKey = cryptoKey - } - - fun setBiometricHandler(biometricHandler: BiometricHandler) { - this.biometricInterface = biometricHandler - } - - override fun deleteKeys(context: Context) { - cryptoKey.deleteKeys() - } - - /** - * Display biometric prompt for authentication type - * @param timeout Timeout for biometric prompt - * @param statusResult Listener for receiving Biometric changes - */ - override suspend fun authenticate(context: Context): DeviceBindingStatus = - withContext(Dispatchers.Main) { - suspendCoroutine { continuation -> - //The keys may be removed due to pin change - val privateKey = cryptoKey.getPrivateKey() - if (privateKey == null) { - continuation.resume(DeviceBindingErrorStatus.ClientNotRegistered()) - } else { - val listener = object : AuthenticationCallback() { - - override fun onAuthenticationError(errorCode: Int, - errString: CharSequence) { - when (errorCode) { - ERROR_CANCELED, ERROR_USER_CANCELED, ERROR_NEGATIVE_BUTTON -> continuation.resume( - DeviceBindingErrorStatus.Abort(errString.toString(), - code = errorCode)) - - ERROR_TIMEOUT -> continuation.resume(DeviceBindingErrorStatus.Timeout( - errString.toString(), - code = errorCode)) - - ERROR_NO_BIOMETRICS, ERROR_NO_DEVICE_CREDENTIAL, ERROR_HW_NOT_PRESENT -> continuation.resume( - DeviceBindingErrorStatus.Unsupported(errString.toString(), - code = errorCode)) - - ERROR_VENDOR -> continuation.resume(DeviceBindingErrorStatus.Unsupported( - errString.toString(), - code = errorCode)) - - ERROR_LOCKOUT_PERMANENT, ERROR_LOCKOUT, ERROR_NO_SPACE, ERROR_HW_UNAVAILABLE, ERROR_UNABLE_TO_PROCESS -> continuation.resume( - DeviceBindingErrorStatus.UnAuthorize(errString.toString(), - code = errorCode)) - - else -> { - continuation.resume(DeviceBindingErrorStatus.Unknown(errString.toString(), - code = errorCode)) - } - } - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - continuation.resume(Success(privateKey)) - } - - override fun onAuthenticationFailed() { - //Ignore with wrong fingerprint - } - } - biometricInterface.authenticate(listener) - } - } - } -} - -/** - * Settings for all the biometric authentication is configured - */ -open class BiometricOnly : BiometricAuthenticator() { - - - /** - * generate the public and private keypair - */ - @SuppressLint("NewApi") - override suspend fun generateKeys(context: Context, attestation: Attestation): KeyPair { - val builder = cryptoKey.keyBuilder() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - builder.setAttestationChallenge(attestation.challenge) - } - if (isApi30OrAbove) { - builder.setUserAuthenticationParameters(cryptoKey.timeout, - KeyProperties.AUTH_BIOMETRIC_STRONG) - } else { - builder.setUserAuthenticationValidityDurationSeconds(cryptoKey.timeout) - } - builder.setUserAuthenticationRequired(true) - val key = cryptoKey.createKeyPair(builder.build()) - return KeyPair(key.public as RSAPublicKey, key.private, cryptoKey.keyAlias) - } - - /** - * check biometric is supported - */ - override fun isSupported(context: Context, attestation: Attestation): Boolean { - return super.isSupported(context, attestation) && - biometricInterface.isSupported(BIOMETRIC_STRONG, BIOMETRIC_WEAK) - } - - final override fun type(): DeviceBindingAuthenticationType = - DeviceBindingAuthenticationType.BIOMETRIC_ONLY - - -} - -/** - * Settings for all the biometric authentication and device credential is configured - */ -open class BiometricAndDeviceCredential : BiometricAuthenticator() { - - /** - * generate the public and private keypair - */ - @SuppressLint("NewApi") - override suspend fun generateKeys(context: Context, attestation: Attestation): KeyPair { - val builder = cryptoKey.keyBuilder() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - builder.setAttestationChallenge(attestation.challenge) - } - if (isApi30OrAbove) { - builder.setUserAuthenticationParameters(cryptoKey.timeout, - KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) - } else { - builder.setUserAuthenticationValidityDurationSeconds(cryptoKey.timeout) - } - builder.setUserAuthenticationRequired(true) - val key = cryptoKey.createKeyPair(builder.build()) - return KeyPair(key.public as RSAPublicKey, key.private, cryptoKey.keyAlias) - } - - /** - * check biometric is supported - */ - override fun isSupported(context: Context, attestation: Attestation): Boolean { - return super.isSupported(context, attestation) && - biometricInterface.isSupported( - BIOMETRIC_STRONG or DEVICE_CREDENTIAL, - BIOMETRIC_WEAK or DEVICE_CREDENTIAL) - } - - final override fun type(): DeviceBindingAuthenticationType = - DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK - - -} - -/** - * Settings for all the none authentication is configured - */ -open class None : CryptoAware, DeviceAuthenticator { - - private lateinit var cryptoKey: CryptoKey - - /** - * generate the public and private keypair - */ - override suspend fun generateKeys(context: Context, attestation: Attestation): KeyPair { - val builder = cryptoKey.keyBuilder() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - builder.setAttestationChallenge(attestation.challenge) - } - val key = cryptoKey.createKeyPair(builder.build()) - return KeyPair(key.public as RSAPublicKey, key.private, cryptoKey.keyAlias) - } - - override fun deleteKeys(context: Context) { - cryptoKey.deleteKeys() - } - - final override fun type(): DeviceBindingAuthenticationType = - DeviceBindingAuthenticationType.NONE - - /** - * return success block for None type - */ - override suspend fun authenticate(context: Context): DeviceBindingStatus { - cryptoKey.getPrivateKey()?.let { - return Success(it) - } ?: return DeviceBindingErrorStatus.ClientNotRegistered() - } - - final override fun setKey(cryptoKey: CryptoKey) { - this.cryptoKey = cryptoKey - } - -} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/DeviceBindingStatus.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/DeviceBindingStatus.kt index 3fdc6b05..a486d4c7 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/DeviceBindingStatus.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/DeviceBindingStatus.kt @@ -7,6 +7,7 @@ package org.forgerock.android.auth.devicebind import java.security.PrivateKey +import java.security.Signature /** * State of the Device Binding errors @@ -53,9 +54,20 @@ abstract class DeviceBindingErrorStatus(var message: String, private val errorType: String = ABORT, private val code: Int? = null) : DeviceBindingErrorStatus(errorMessage, errorType, code) + + data class InvalidCustomClaims(private val errorMessage: String = "Invalid Custom Claims", + private val errorType: String = ABORT, + private val code: Int? = null) : + DeviceBindingErrorStatus(errorMessage, errorType, code) } -data class Success(val privateKey: PrivateKey) : DeviceBindingStatus +/** + * Represent the success status after [DeviceAuthenticator.authenticate] + * + * @property privateKey The unlocked private key + * @property signature The unlocked signature + */ +data class Success(val privateKey: PrivateKey, val signature: Signature? = null) : DeviceBindingStatus /** * Exceptions for device binding diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/None.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/None.kt new file mode 100644 index 00000000..6e3e824c --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/None.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.devicebind + +import android.content.Context +import android.os.Build +import org.forgerock.android.auth.CryptoKey +import org.forgerock.android.auth.callback.Attestation +import org.forgerock.android.auth.callback.DeviceBindingAuthenticationType +import java.security.interfaces.RSAPublicKey + +/** + * Settings for all the none authentication is configured + */ +open class None : CryptoAware, DeviceAuthenticator { + + private lateinit var cryptoKey: CryptoKey + + /** + * generate the public and private keypair + */ + override suspend fun generateKeys(context: Context, attestation: Attestation): KeyPair { + val builder = cryptoKey.keyBuilder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setAttestationChallenge(attestation.challenge) + } + val key = cryptoKey.createKeyPair(builder.build()) + return KeyPair(key.public as RSAPublicKey, key.private, cryptoKey.keyAlias) + } + + override fun deleteKeys(context: Context) { + cryptoKey.deleteKeys() + } + + final override fun type(): DeviceBindingAuthenticationType = + DeviceBindingAuthenticationType.NONE + + /** + * return success block for None type + */ + override suspend fun authenticate(context: Context): DeviceBindingStatus { + cryptoKey.getPrivateKey()?.let { + return Success(it) + } ?: return DeviceBindingErrorStatus.ClientNotRegistered() + } + + final override fun setKey(cryptoKey: CryptoKey) { + this.cryptoKey = cryptoKey + } + +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/RSASASignatureSigner.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/RSASASignatureSigner.kt new file mode 100644 index 00000000..1f9432f5 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/devicebind/RSASASignatureSigner.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.devicebind + +import com.nimbusds.jose.JOSEException +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.crypto.impl.RSASSA +import com.nimbusds.jose.crypto.impl.RSASSAProvider +import com.nimbusds.jose.util.Base64URL +import java.security.InvalidKeyException +import java.security.PrivateKey +import java.security.Signature +import java.security.SignatureException + +/** + * A [JWSSigner] which takes a signature + */ +internal class RSASASignatureSigner(val signature: Signature) : RSASSAProvider(), JWSSigner { + + @Throws(JOSEException::class) + override fun sign(header: JWSHeader, signingInput: ByteArray): Base64URL { + return sign(signingInput, signature) + } + + private fun sign(signingInput: ByteArray, signer: Signature): Base64URL { + return try { + signer.update(signingInput) + Base64URL.encode(signer.sign()) + } catch (e: SignatureException) { + throw JOSEException("RSA signature exception: " + e.message, e) + } + } +} + +/** + * Create a [Signature] with the provided [JWSAlgorithm] and [PrivateKey] + */ +internal fun RSASSAProvider.signature(jwsAlgorithm: JWSAlgorithm, privateKey: PrivateKey): Signature { + val signer = RSASSA.getSignerAndVerifier(jwsAlgorithm, jcaContext.provider) + try { + signer.initSign(privateKey) + } catch (e: InvalidKeyException) { + throw JOSEException("Invalid private RSA key: " + e.message, e) + } + return signer +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/WebAuthnResponseException.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/WebAuthnResponseException.kt index 2062d076..0cb9b629 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/WebAuthnResponseException.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/WebAuthnResponseException.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -12,14 +12,11 @@ import com.google.android.gms.fido.fido2.api.common.ErrorCode /** * An Exception representation of [AuthenticatorErrorResponse] */ -class WebAuthnResponseException(authenticatorErrorResponse: AuthenticatorErrorResponse) : - Exception(authenticatorErrorResponse.errorMessage) { - val errorCode: ErrorCode - val errorCodeAsInt: Int +class WebAuthnResponseException(val errorCode: ErrorCode, errorMessage: String?) : + Exception(errorMessage) { - init { - errorCode = authenticatorErrorResponse.errorCode - errorCodeAsInt = authenticatorErrorResponse.errorCodeAsInt + constructor(authenticatorErrorResponse: AuthenticatorErrorResponse) : + this(authenticatorErrorResponse.errorCode, authenticatorErrorResponse.errorMessage) { } /** diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/idp/AppleSignInHandler.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/idp/AppleSignInHandler.java index 2b3d02a7..66071e51 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/idp/AppleSignInHandler.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/idp/AppleSignInHandler.java @@ -64,11 +64,12 @@ public void onCreate(@Nullable Bundle savedInstanceState) { idPClient.getClientId(), CODE, Uri.parse(idPClient.getRedirectUri())) - .setAdditionalParameters(Collections.singletonMap("nonce", idPClient.getNonce())) .setScopes(idPClient.getScopes()) .setState(null) .setResponseMode(FORM_POST); + authRequestBuilder.setNonce(idPClient.getNonce()); + AuthorizationRequest authorizationRequest = authRequestBuilder .build(); AppAuthConfiguration.Builder appAuthConfigurationBuilder = new AppAuthConfiguration.Builder(); diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/webauthn/WebAuthnFragment.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/webauthn/WebAuthnFragment.kt index 787402d6..977e6718 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/webauthn/WebAuthnFragment.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/webauthn/WebAuthnFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -17,9 +17,9 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import com.google.android.gms.fido.Fido import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse +import com.google.android.gms.fido.fido2.api.common.ErrorCode import com.google.android.gms.fido.fido2.api.common.PublicKeyCredential import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine import org.forgerock.android.auth.InitProvider import org.forgerock.android.auth.exception.WebAuthnException @@ -35,6 +35,7 @@ private const val PENDING_INTENT = "pendingIntent" class WebAuthnFragment : Fragment() { private var pendingIntent: PendingIntent? = null + //Cannot cancel the pending Intent when cancel, the pendingIntent is with com.google.android.gms private var continuation: CancellableContinuation? = null @@ -60,8 +61,15 @@ class WebAuthnFragment : Fragment() { private fun handleSignResult(activityResult: ActivityResult) { val bytes = activityResult.data?.getByteArrayExtra(Fido.FIDO2_KEY_CREDENTIAL_EXTRA) when { + //Android FIDO SDK is inconsistent of handling user cancellation, it may returns + //RESULT_CANCELED in some cases, translate to WebAuthn error code + activityResult.resultCode == Activity.RESULT_CANCELED -> continuation?.resumeWithException( + WebAuthnResponseException(ErrorCode.NOT_ALLOWED_ERR, + "Did not get user selected credential")) + activityResult.resultCode != Activity.RESULT_OK -> continuation?.resumeWithException( WebAuthnException("error")) + bytes == null -> continuation?.resumeWithException(WebAuthnException("error")) else -> { val credential = PublicKeyCredential.deserializeFromBytes(bytes) diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/ConfigHelperTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/ConfigHelperTest.kt index 0d186ee6..8643418d 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/ConfigHelperTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/ConfigHelperTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -68,69 +68,141 @@ class ConfigHelperTest { urlPath { revokeEndpoint = "https://revoke" endSessionEndpoint = "https://endsession" + sessionEndpoint = "https://sessionEndpoint" } } val cookieChanged = FROptionsBuilder.build { server { - url = "" - realm = "" + url = "https://dummy" + realm = "realm123" cookieName = "cookieName1" } + oauth { + oauthClientId = "client_id" + oauthScope = "scope" + oauthRedirectUri = "redirecturi" + } + urlPath { + revokeEndpoint = "https://revoke" + endSessionEndpoint = "https://endsession" + } } val realmChanged = FROptionsBuilder.build { server { - url = "" - realm = "" - realm = "realm1" + url = "https://dummy" + realm = "realm1234" + cookieName = "cookieName" + } + oauth { + oauthClientId = "client_id" + oauthScope = "scope" + oauthRedirectUri = "redirecturi" + } + urlPath { + revokeEndpoint = "https://revoke" + endSessionEndpoint = "https://endsession" } } val clientChanged = FROptionsBuilder.build { server { - url = "" - realm = "" - cookieName = "cookieName1" + url = "https://dummy" + realm = "realm123" + cookieName = "cookieName" } oauth { - oauthClientId = "clientId" + oauthClientId = "client_id_1" + oauthScope = "scope" + oauthRedirectUri = "redirecturi" + } + urlPath { + revokeEndpoint = "https://revoke" + endSessionEndpoint = "https://endsession" } } val redirectURIChanged = FROptionsBuilder.build { server { - url = "" - realm = "" - cookieName = "cookieName1" + url = "https://dummy" + realm = "realm123" + cookieName = "cookieName" } oauth { - oauthRedirectUri = "https://redirectURI" + oauthClientId = "client_id" + oauthScope = "scope" + oauthRedirectUri = "redirecturi_uri" + } + urlPath { + revokeEndpoint = "https://revoke" + endSessionEndpoint = "https://endsession" } } val scopeChanged = FROptionsBuilder.build { server { - url = "" - realm = "" - cookieName = "cookieName1" + url = "https://dummy" + realm = "realm123" + cookieName = "cookieName" } oauth { - oauthScope = "scope" + oauthClientId = "client_id" + oauthScope = "scope_test" + oauthRedirectUri = "redirecturi" + } + urlPath { + revokeEndpoint = "https://revoke" + endSessionEndpoint = "https://endsession" } } - val urlChanged = FROptionsBuilder.build { + val expectedURL = FROptionsBuilder.build { server { - url = "dummy" - realm = "" + url = "https://dummynew" + realm = "realm123" + cookieName = "cookieName" + } + oauth { + oauthClientId = "client_id" + oauthScope = "scope" + oauthRedirectUri = "redirecturi" + } + urlPath { + revokeEndpoint = "https://revoke" + endSessionEndpoint = "https://endsession" } } ConfigHelper.persist(context, frOptions) assertTrue(ConfigHelper.isConfigDifferentFromPersistedValue(context, cookieChanged)) assertTrue(ConfigHelper.isConfigDifferentFromPersistedValue(context, realmChanged)) assertTrue(ConfigHelper.isConfigDifferentFromPersistedValue(context, clientChanged)) - assertTrue(ConfigHelper.isConfigDifferentFromPersistedValue(context, urlChanged)) + assertTrue(ConfigHelper.isConfigDifferentFromPersistedValue(context, expectedURL)) assertTrue(ConfigHelper.isConfigDifferentFromPersistedValue(context, redirectURIChanged)) assertTrue(ConfigHelper.isConfigDifferentFromPersistedValue(context, scopeChanged)) assertFalse(ConfigHelper.isConfigDifferentFromPersistedValue(context, frOptions)) val preference = ConfigHelper.loadFromPreference(context) - assertTrue(preference == frOptions) + assertEquals(preference, frOptions) + + } + + @Test + fun loadDefaultValueFromPreference() { + val expectedValue = FROptionsBuilder.build { + server { + url = "https://openam.example.com:8081/openam" + realm = "root" + cookieName = "iPlanetDirectoryPro" + } + oauth { + oauthClientId = "andy_app" + oauthScope = "openid email address" + oauthRedirectUri = "https://www.example.com:8080/callback" + } + urlPath { + revokeEndpoint = "" + endSessionEndpoint = "" + sessionEndpoint = "" + + } + } + val preference = ConfigHelper.loadFromPreference(context) + assertEquals(preference, expectedValue) } @Test diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/FROptionTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/FROptionTest.kt index 847ba77d..84ddac78 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/FROptionTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/FROptionTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -59,10 +59,39 @@ class FROptionTest { assertTrue(option.logger.customLogger == logger) } + @Test(expected = IllegalArgumentException::class) + fun testInValidConfigRealm() { + val option1 = FROptionsBuilder.build { server { + url = "https://stoyan.com" + realm = "" + }} + option1.validateConfig() + } + + @Test(expected = IllegalArgumentException::class) + fun testInValidConfigCookieName() { + val option1 = FROptionsBuilder.build { server { + url = "https://stoyan.com" + cookieName = "" + }} + option1.validateConfig() + } + + @Test(expected = IllegalArgumentException::class) + fun testInValidConfigUrl() { + val option1 = FROptionsBuilder.build { server { + url = "" + }} + option1.validateConfig() + } @Test fun testReferenceAndValue() { - val option1 = FROptionsBuilder.build { } - var option2 = FROptionsBuilder.build { } + val option1 = FROptionsBuilder.build { server { + url = "https://stoyan.com" + }} + var option2 = FROptionsBuilder.build { server { + url = "https://stoyan.com" + }} assertTrue(FROptions.equals(option1, option2)) option2 = FROptionsBuilder.build { server { url = "https://andy.com" diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/FRUserMockTest.java b/forgerock-auth/src/test/java/org/forgerock/android/auth/FRUserMockTest.java index 55da51b9..2b1186fd 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/FRUserMockTest.java +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/FRUserMockTest.java @@ -7,12 +7,25 @@ package org.forgerock.android.auth; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.net.Uri; import android.util.Pair; +import androidx.annotation.NonNull; + import com.squareup.okhttp.mockwebserver.MockResponse; import com.squareup.okhttp.mockwebserver.RecordedRequest; @@ -44,21 +57,14 @@ import java.net.URL; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import okhttp3.Cookie; @RunWith(RobolectricTestRunner.class) public class FRUserMockTest extends BaseTest { @@ -1073,6 +1079,43 @@ public void testRequestInterceptor() throws InterruptedException, ExecutionExcep Assertions.assertThat(result.get("USER_INFO").second).isEqualTo(1); } + @Test + public void testCookieInterceptor() throws InterruptedException, ExecutionException, MalformedURLException, JSONException, ParseException { + + //Total e request will be intercepted + CountDownLatch countDownLatch = new CountDownLatch(8); + RequestInterceptorRegistry.getInstance().register((CustomCookieInterceptor) cookies -> { + countDownLatch.countDown(); + List customizedCookies = new ArrayList<>(); + customizedCookies.add(new Cookie.Builder().domain("localhost").name("test").value("testValue").build()); + customizedCookies.addAll(cookies); + return customizedCookies; + }); + + frUserHappyPath(); + + //Logout + server.enqueue(new MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader("Content-Type", "application/json") + .setBody("{}")); + enqueue("/sessions_logout.json", HttpURLConnection.HTTP_OK); + + + FRUser.getCurrentUser().logout(); + countDownLatch.await(); + + //Take few requests and make sure it contains the custom header. + RecordedRequest recordedRequest = server.takeRequest(); + assertThat(recordedRequest.getHeader("Cookie")).isEqualTo("test=testValue"); + recordedRequest = server.takeRequest(); + assertThat(recordedRequest.getHeader("Cookie")).isEqualTo("test=testValue"); + recordedRequest = server.takeRequest(); + + + } + + @Test public void testAccessTokenWithExpiredRefreshToken() throws ExecutionException, InterruptedException, AuthenticationRequiredException, ApiException, IOException { enqueue("/authTreeMockTest_Authenticate_NameCallback.json", HttpURLConnection.HTTP_OK); @@ -1459,4 +1502,12 @@ public void onCallbackReceived(Node state) { Assertions.assertThat(Config.getInstance().getSingleSignOnManager().getToken()).isNotNull(); } + + private interface CustomCookieInterceptor extends FRRequestInterceptor, CookieInterceptor { + @NonNull + @Override + default Request intercept(@NonNull Request request, Action tag) { + return request; + } + } } diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/AbstractTask.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/AbstractTask.kt new file mode 100644 index 00000000..5e28185c --- /dev/null +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/AbstractTask.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.callback + +import android.app.Activity +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +import java.lang.Exception +import java.lang.UnsupportedOperationException +import java.util.concurrent.Executor + +abstract class AbstractTask : Task() { + override fun addOnFailureListener(p0: OnFailureListener): Task { + throw UnsupportedOperationException() + } + + override fun addOnFailureListener(p0: Activity, p1: OnFailureListener): Task { + throw UnsupportedOperationException() + } + + override fun addOnFailureListener(p0: Executor, p1: OnFailureListener): Task { + throw UnsupportedOperationException() + } + + override fun getException(): Exception? { + throw UnsupportedOperationException() + } + + override fun getResult(): T { + throw UnsupportedOperationException() + } + + override fun getResult(p0: Class): T { + throw UnsupportedOperationException() + } + + override fun isCanceled(): Boolean { + throw UnsupportedOperationException() + } + + override fun isComplete(): Boolean { + throw UnsupportedOperationException() + } + + override fun isSuccessful(): Boolean { + throw UnsupportedOperationException() + } + + override fun addOnSuccessListener(p0: Executor, p1: OnSuccessListener): Task { + throw UnsupportedOperationException() + } + + override fun addOnSuccessListener(p0: Activity, p1: OnSuccessListener): Task { + throw UnsupportedOperationException() + } + + override fun addOnSuccessListener(p0: OnSuccessListener): Task { + throw UnsupportedOperationException() + } +} \ No newline at end of file diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/AppIntegrityCallbackTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/AppIntegrityCallbackTest.kt new file mode 100644 index 00000000..80f1a44a --- /dev/null +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/AppIntegrityCallbackTest.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.callback + +import android.app.Activity +import android.content.Context +import android.content.res.Resources.NotFoundException +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.tasks.Task +import com.google.android.play.core.integrity.IntegrityManager +import com.google.android.play.core.integrity.IntegrityTokenRequest +import com.google.android.play.core.integrity.IntegrityTokenResponse +import com.google.android.play.core.integrity.StandardIntegrityManager +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityToken +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.fail +import org.forgerock.android.auth.Node +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppIntegrityCallbackTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val integrityManager = object : IntegrityManager { + override fun requestIntegrityToken(request: IntegrityTokenRequest): Task { + return object : AbstractTask() { + + override fun getException(): java.lang.Exception? { + return null + } + + override fun getResult(): IntegrityTokenResponse { + return object : IntegrityTokenResponse() { + override fun showDialog(p0: Activity?, p1: Int): Task { + TODO("Not yet implemented") + } + + override fun token(): String { + val token = JSONObject() + token.put("value", "test-integrity-token") + token.put("nonce", request.nonce()) + return token.toString() + } + } + } + + override fun isCanceled(): Boolean { + return false + } + + override fun isComplete(): Boolean { + return true + } + + override fun isSuccessful(): Boolean { + return true + } + } + } + } + + private val integrityManagerWithException = object : IntegrityManager { + override fun requestIntegrityToken(request: IntegrityTokenRequest): Task { + return object : AbstractTask() { + override fun getException(): Exception { + return NotFoundException() + } + + override fun getResult(): IntegrityTokenResponse { + return object : IntegrityTokenResponse() { + override fun showDialog(p0: Activity?, p1: Int): Task { + TODO("Not yet implemented") + } + + override fun token(): String { + return "test-integrity-token" + } + } + } + + override fun getResult(p0: Class): IntegrityTokenResponse { + throw UnsupportedOperationException() + } + + override fun isCanceled(): Boolean { + return false + } + + override fun isComplete(): Boolean { + return true + } + + override fun isSuccessful(): Boolean { + return false + } + } + } + } + + private val standardIntegrityManager = object : StandardIntegrityManager { + + override fun prepareIntegrityToken(request: StandardIntegrityManager.PrepareIntegrityTokenRequest): Task { + return object : + AbstractTask() { + + override fun getException(): java.lang.Exception? { + return null + } + + override fun getResult(): StandardIntegrityManager.StandardIntegrityTokenProvider { + return object : StandardIntegrityManager.StandardIntegrityTokenProvider { + override fun request(request: StandardIntegrityManager.StandardIntegrityTokenRequest): Task { + return object : AbstractTask() { + override fun getException(): Exception? { + return null + } + + override fun getResult(): StandardIntegrityToken { + return object : StandardIntegrityToken() { + override fun showDialog(p0: Activity?, p1: Int): Task { + TODO("Not yet implemented") + } + + override fun token(): String { + val token = JSONObject() + token.put("value", "test-standard-integrity-token") + token.put("requestHash", request.a()) + return token.toString() + } + } + } + + override fun getResult(p0: Class): StandardIntegrityToken { + throw UnsupportedOperationException() + } + + override fun isCanceled(): Boolean { + return false + } + + override fun isComplete(): Boolean { + return true + } + + override fun isSuccessful(): Boolean { + return false + } + } + } + } + } + + override fun isCanceled(): Boolean { + return false + } + + override fun isComplete(): Boolean { + return true + } + + override fun isSuccessful(): Boolean { + return true + } + } + } + } + + private val content = "{\n" + + " \"type\": \"AppIntegrityCallback\",\n" + + " \"output\": [\n" + + " {\n" + + " \"name\": \"requestType\",\n" + + " \"value\": \"Classic\"\n" + + " },\n" + + " {\n" + + " \"name\": \"projectNumber\",\n" + + " \"value\": \"12345678\"\n" + + " },\n" + + " {\n" + + " \"name\": \"nonce\",\n" + + " \"value\": \"FsFTX3CInUu3qgDL_B90EvFNDMyQS3-v2zG6gjIhKhU\"\n" + + " }\n" + + " ],\n" + + " \"input\": [\n" + + " {\n" + + " \"name\": \"IDToken1token\",\n" + + " \"value\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"IDToken1clientError\",\n" + + " \"value\": \"\"\n" + + " }\n" + + " ]\n" + + " }" + + private val standard = "{\n" + + " \"type\": \"AppIntegrityCallback\",\n" + + " \"output\": [\n" + + " {\n" + + " \"name\": \"requestType\",\n" + + " \"value\": \"Standard\"\n" + + " },\n" + + " {\n" + + " \"name\": \"projectNumber\",\n" + + " \"value\": \"12345678\"\n" + + " },\n" + + " {\n" + + " \"name\": \"nonce\",\n" + + " \"value\": \"FsFTX3CInUu3qgDL_B90EvFNDMyQS3-v2zG6gjIhKhU\"\n" + + " }\n" + + " ],\n" + + " \"input\": [\n" + + " {\n" + + " \"name\": \"IDToken1token\",\n" + + " \"value\": \"\"\n" + + " },\n" + + " {\n" + + " \"name\": \"IDToken1clientError\",\n" + + " \"value\": \"\"\n" + + " }\n" + + " ]\n" + + " }" + + + private val node = Node("dummy-auth-id", "", "", + "", "", mutableListOf()) + + + @Test + fun testValuesAreSetProperly() { + val callback = AppIntegrityCallback(JSONObject(content), 0) + callback.setNode(node) + assertThat(callback.nonce).isEqualTo("FsFTX3CInUu3qgDL_B90EvFNDMyQS3-v2zG6gjIhKhU") + assertThat(callback.projectNumber).isEqualTo("12345678") + assertThat(callback.type).isEqualTo("AppIntegrityCallback") + } + + @Test + fun testClassicIntegrity(): Unit = runBlocking { + val callback = object : AppIntegrityCallback(JSONObject(content), 0) { + override fun getIntegrityManager(context: Context): IntegrityManager { + return integrityManager + } + } + callback.setNode(node) + callback.requestIntegrityToken(context) + val token = JSONObject(callback.getInputValue(0) as String) + assertThat(token.getString("value")).isEqualTo("test-integrity-token") + assertThat(token.getString("nonce")).isEqualTo("aGW6dVKHOUQMCc8LwYWd15beWfFv07qJUfBKT3ocZp0=") + } + + @Test + fun testStandardIntegrity(): Unit = runBlocking { + val callback = object : AppIntegrityCallback(JSONObject(standard), 0) { + override fun getStandardIntegrityManager(context: Context): StandardIntegrityManager { + return standardIntegrityManager + } + } + callback.clearCache(); + callback.setNode(node) + callback.requestIntegrityToken(context) + val token = JSONObject(callback.getInputValue(0) as String) + assertThat(token.getString("value")).isEqualTo("test-standard-integrity-token") + assertThat(token.getString("requestHash")).isEqualTo("96HWZnzgu2NkhBtEHJy_66ee_eo_hGNj-jHoOw97o3w=") + assertThat(AppIntegrityCallback.cache.size).isEqualTo(1) + } + + @Test + fun testException(): Unit = runBlocking { + val callback = object : AppIntegrityCallback(JSONObject(content), 0) { + override fun getIntegrityManager(context: Context): IntegrityManager { + return integrityManagerWithException + } + } + callback.setNode(node) + try { + callback.requestIntegrityToken(context) + fail("Should failed") + } catch (exception: Exception) { + assertThat(callback.getInputValue(1)).isEqualTo("ClientDeviceErrors") + assertThat(exception).isInstanceOf(NotFoundException::class.java) + } + } + + @Test + fun testCustomError() { + val callback = AppIntegrityCallback(JSONObject(content), 0) + callback.setClientError("test-error") + assertThat(callback.getInputValue(1)).isEqualTo("test-error") + } + +} \ No newline at end of file diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DeviceBindingCallbackTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DeviceBindingCallbackTest.kt index 1990fc98..1c071fe4 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DeviceBindingCallbackTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DeviceBindingCallbackTest.kt @@ -93,7 +93,7 @@ class DeviceBindingCallbackTest { whenever(deviceAuthenticator.isSupported(any(), any())).thenReturn(true) whenever(deviceAuthenticator.generateKeys(any(), any())).thenReturn(keyPair) whenever(deviceAuthenticator.authenticate(any())).thenReturn(Success(keyPair.privateKey)) - whenever(deviceAuthenticator.sign(context, keyPair, + whenever(deviceAuthenticator.sign(context, keyPair, null, kid, userid, challenge, @@ -116,7 +116,7 @@ class DeviceBindingCallbackTest { whenever(deviceAuthenticator.isSupported(any(), any())).thenReturn(true) whenever(deviceAuthenticator.generateKeys(any(), any())).thenReturn(keyPair) whenever(deviceAuthenticator.authenticate(any())).thenReturn(Success(keyPair.privateKey)) - whenever(deviceAuthenticator.sign(context, keyPair, + whenever(deviceAuthenticator.sign(context, keyPair, null, kid, userid, challenge, @@ -144,7 +144,7 @@ class DeviceBindingCallbackTest { whenever(deviceAuthenticator.isSupported(any(), any())).thenReturn(true) whenever(deviceAuthenticator.generateKeys(any(), any())).thenReturn(keyPair) whenever(deviceAuthenticator.authenticate(any())).thenReturn(Success(keyPair.privateKey)) - whenever(deviceAuthenticator.sign(context, keyPair, + whenever(deviceAuthenticator.sign(context, keyPair, null, kid, userid, challenge, @@ -184,7 +184,13 @@ class DeviceBindingCallbackTest { verify(encryptedPref, times(0)).delete(any()) //The key reference has not been created //Delete before and delete after when failed verify(deviceAuthenticator, times(2)).deleteKeys(any()) - verify(deviceAuthenticator, times(0)).sign(context, keyPair, kid, userid, challenge, getExpiration()) + verify(deviceAuthenticator, times(0)).sign(context, + keyPair, + null, + kid, + userid, + challenge, + getExpiration()) verify(encryptedPref, times(0)).persist(any()) } @@ -212,7 +218,13 @@ class DeviceBindingCallbackTest { verify(encryptedPref, times(0)).delete(any()) //Delete before and delete after when failed verify(deviceAuthenticator, times(2)).deleteKeys(any()) - verify(deviceAuthenticator, times(0)).sign(context, keyPair, kid, userid, challenge, getExpiration()) + verify(deviceAuthenticator, times(0)).sign(context, + keyPair, + null, + kid, + userid, + challenge, + getExpiration()) verify(encryptedPref, times(0)).persist(any()) } @@ -242,7 +254,13 @@ class DeviceBindingCallbackTest { verify(encryptedPref, times(0)).delete(any()) verify(deviceAuthenticator, times(0)).deleteKeys(any()) verify(deviceAuthenticator, times(0)).authenticate(any()) - verify(deviceAuthenticator, times(0)).sign(context, keyPair, kid, userid, challenge, getExpiration()) + verify(deviceAuthenticator, times(0)).sign(context, + keyPair, + null, + kid, + userid, + challenge, + getExpiration()) verify(encryptedPref, times(0)).persist(any()) } @@ -252,7 +270,8 @@ class DeviceBindingCallbackTest { val rawContent = "{\"type\":\"DeviceBindingCallback\",\"output\":[{\"name\":\"userId\",\"value\":\"id=demo,ou=user,dc=openam,dc=forgerock,dc=org\"},{\"name\":\"username\",\"value\":\"demo\"},{\"name\":\"authenticationType\",\"value\":\"APPLICATION_PIN\"},{\"name\":\"challenge\",\"value\":\"CS3+g40VkHXx+dN7rpnJKhrEAvwZaYgbaXoEcpO5twM=\"},{\"name\":\"title\",\"value\":\"Authentication required\"},{\"name\":\"subtitle\",\"value\":\"Cryptography device binding\"},{\"name\":\"description\",\"value\":\"Please complete with biometric to proceed\"},{\"name\":\"timeout\",\"value\":60},{\"name\":\"attestation\",\"value\":false}],\"input\":[{\"name\":\"IDToken1jws\",\"value\":\"\"},{\"name\":\"IDToken1deviceName\",\"value\":\"\"},{\"name\":\"IDToken1deviceId\",\"value\":\"\"},{\"name\":\"IDToken1clientError\",\"value\":\"\"}]}"; whenever(deviceAuthenticator.isSupported(any(), any())).thenReturn(true) - whenever(deviceAuthenticator.generateKeys(any(), any())).thenThrow(NullPointerException::class.java) + whenever(deviceAuthenticator.generateKeys(any(), + any())).thenThrow(NullPointerException::class.java) val testObject = DeviceBindingCallbackMockTest(rawContent) try { testObject.testExecute(context, @@ -268,7 +287,13 @@ class DeviceBindingCallbackTest { verify(encryptedPref, times(0)).delete(any()) verify(deviceAuthenticator, times(2)).deleteKeys(any()) verify(deviceAuthenticator, times(0)).authenticate(any()) - verify(deviceAuthenticator, times(0)).sign(context, keyPair, kid, userid, challenge, getExpiration()) + verify(deviceAuthenticator, times(0)).sign(context, + keyPair, + null, + kid, + userid, + challenge, + getExpiration()) verify(encryptedPref, times(0)).persist(any()) } @@ -298,7 +323,6 @@ class DeviceBindingCallbackTest { } - class DeviceBindingCallbackMockTest constructor(rawContent: String, jsonObject: JSONObject = JSONObject(rawContent), value: Int = 0) : @@ -326,36 +350,37 @@ class DeviceBindingCallbackMockTest constructor(rawContent: String, override fun getDeviceAuthenticator(type: DeviceBindingAuthenticationType): DeviceAuthenticator { if (type == DeviceBindingAuthenticationType.APPLICATION_PIN) { - val deviceAuthenticator = object : ApplicationPinDeviceAuthenticator(object : PinCollector { - override suspend fun collectPin(prompt: Prompt, - fragmentActivity: FragmentActivity): CharArray { - return "1234".toCharArray() - } - }) { - var byteArrayOutputStream = ByteArrayOutputStream(1024) - - override fun getInputStream(context: Context): InputStream { - return byteArrayOutputStream.toByteArray().inputStream(); - } - - override fun getOutputStream(context: Context): OutputStream { - return byteArrayOutputStream - } + val deviceAuthenticator = + object : ApplicationPinDeviceAuthenticator(object : PinCollector { + override suspend fun collectPin(prompt: Prompt, + fragmentActivity: FragmentActivity): CharArray { + return "1234".toCharArray() + } + }) { + var byteArrayOutputStream = ByteArrayOutputStream(1024) + + override fun getInputStream(context: Context): InputStream { + return byteArrayOutputStream.toByteArray().inputStream(); + } + + override fun getOutputStream(context: Context): OutputStream { + return byteArrayOutputStream + } + + override fun getKeystoreType(): String { + return "PKCS12" + } + + override fun delete(context: Context) { + byteArrayOutputStream = ByteArrayOutputStream(1024) + } + + override fun exist(context: Context): Boolean { + return byteArrayOutputStream.toByteArray().isNotEmpty() + } - override fun getKeystoreType(): String { - return "PKCS12" } - override fun delete(context: Context) { - byteArrayOutputStream = ByteArrayOutputStream(1024) - } - - override fun exist(context: Context): Boolean { - return byteArrayOutputStream.toByteArray().isNotEmpty() - } - - } - return deviceAuthenticator } return super.getDeviceAuthenticator(type) diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallbackTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallbackTest.kt index 48f2ca44..5e817912 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallbackTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DeviceSigningVerifierCallbackTest.kt @@ -13,6 +13,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.runBlocking import org.forgerock.android.auth.devicebind.DeviceAuthenticator import org.forgerock.android.auth.devicebind.DeviceBindFragment +import org.forgerock.android.auth.devicebind.DeviceBindingErrorStatus import org.forgerock.android.auth.devicebind.DeviceBindingException import org.forgerock.android.auth.devicebind.KeyPair import org.forgerock.android.auth.devicebind.MultipleKeysFound @@ -24,6 +25,7 @@ import org.forgerock.android.auth.devicebind.UserKeySelector import org.forgerock.android.auth.devicebind.UserKeyService import org.forgerock.android.auth.devicebind.UserKeys import org.json.JSONObject +import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -85,9 +87,11 @@ class DeviceSigningVerifierCallbackTest { UserKey("id1", "jey", "jey", "kid", DeviceBindingAuthenticationType.NONE, System.currentTimeMillis()) whenever(userKeyService.getKeyStatus("jey")).thenReturn(SingleKeyFound(userKey)) whenever(deviceAuthenticator.isSupported(context)).thenReturn(true) + whenever(deviceAuthenticator.validateCustomClaims(any())).thenReturn(true) whenever(deviceAuthenticator.authenticate(any())).thenReturn(Success(keyPair.privateKey)) whenever(deviceAuthenticator.sign(context, userKey, keyPair.privateKey, + null, "zYwKaKnqS2YzvhXSK+sFjC7FKBoprArqz6LpJ8qe9+g=", getExpiration())).thenReturn("jws") @@ -114,11 +118,13 @@ class DeviceSigningVerifierCallbackTest { whenever(userKeyService.getKeyStatus("jey")).thenReturn(MultipleKeysFound(mutableListOf( userKey, userKey1))) + whenever(deviceAuthenticator.validateCustomClaims(any())).thenReturn(true) whenever(deviceAuthenticator.isSupported(context)).thenReturn(true) whenever(deviceAuthenticator.authenticate(any())).thenReturn(Success(keyPair.privateKey)) whenever(deviceAuthenticator.sign(context, userKey, keyPair.privateKey, + null, "zYwKaKnqS2YzvhXSK+sFjC7FKBoprArqz6LpJ8qe9+g=", getExpiration())).thenReturn("jws") val key = UserKey("id1", "jey", "jey", "kid", DeviceBindingAuthenticationType.NONE, System.currentTimeMillis()) @@ -138,6 +144,7 @@ class DeviceSigningVerifierCallbackTest { whenever(deviceAuthenticator.sign(context, userKey, keyPair.privateKey, + null, "zYwKaKnqS2YzvhXSK+sFjC7FKBoprArqz6LpJ8qe9+g=", getExpiration())).thenReturn("jws") @@ -151,6 +158,57 @@ class DeviceSigningVerifierCallbackTest { date.add(Calendar.SECOND, 60) return date.time; } + + fun testSignForForValidClaims() = runBlocking { + val rawContent = + "{\"type\":\"DeviceSigningVerifierCallback\",\"output\":[{\"name\":\"userId\",\"value\":\"jey\"},{\"name\":\"challenge\",\"value\":\"zYwKaKnqS2YzvhXSK+sFjC7FKBoprArqz6LpJ8qe9+g=\"},{\"name\":\"title\",\"value\":\"Authentication required\"},{\"name\":\"subtitle\",\"value\":\"Cryptography device binding\"},{\"name\":\"description\",\"value\":\"Please complete with biometric to proceed\"},{\"name\":\"timeout\",\"value\":20}],\"input\":[{\"name\":\"IDToken1jws\",\"value\":\"\"},{\"name\":\"IDToken1clientError\",\"value\":\"\"}]}" + val userKey = + UserKey("id1", "jey", "jey", "kid", DeviceBindingAuthenticationType.NONE, System.currentTimeMillis()) + whenever(userKeyService.getKeyStatus("jey")).thenReturn(SingleKeyFound(userKey)) + whenever(deviceAuthenticator.isSupported(context)).thenReturn(true) + whenever(deviceAuthenticator.authenticate(any())).thenReturn(Success(keyPair.privateKey)) + whenever(deviceAuthenticator.validateCustomClaims(any())).thenReturn(true) + whenever(deviceAuthenticator.sign(context, userKey, + keyPair.privateKey, + null, + "zYwKaKnqS2YzvhXSK+sFjC7FKBoprArqz6LpJ8qe9+g=", + getExpiration())).thenReturn("jws") + + val testObject = + DeviceSigningVerifierCallbackMock(rawContent) + testObject.executeAuthenticate(context, userKey, deviceAuthenticator) + } + + + fun testSignForForInvalidClaims() = runBlocking { + val errorCode = -1 + val invalidCustomClaims = DeviceBindingErrorStatus.InvalidCustomClaims(code = errorCode) + val rawContent = + "{\"type\":\"DeviceSigningVerifierCallback\",\"output\":[{\"name\":\"userId\",\"value\":\"jey\"},{\"name\":\"challenge\",\"value\":\"zYwKaKnqS2YzvhXSK+sFjC7FKBoprArqz6LpJ8qe9+g=\"},{\"name\":\"title\",\"value\":\"Authentication required\"},{\"name\":\"subtitle\",\"value\":\"Cryptography device binding\"},{\"name\":\"description\",\"value\":\"Please complete with biometric to proceed\"},{\"name\":\"timeout\",\"value\":20}],\"input\":[{\"name\":\"IDToken1jws\",\"value\":\"\"},{\"name\":\"IDToken1clientError\",\"value\":\"\"}]}" + val userKey = + UserKey("id1", "jey", "jey", "kid", DeviceBindingAuthenticationType.NONE, System.currentTimeMillis()) + whenever(userKeyService.getKeyStatus("jey")).thenReturn(SingleKeyFound(userKey)) + whenever(deviceAuthenticator.isSupported(context)).thenReturn(true) + whenever(deviceAuthenticator.authenticate(any())).thenReturn(Success(keyPair.privateKey)) + whenever(deviceAuthenticator.validateCustomClaims(any())).thenReturn(false) + whenever(deviceAuthenticator.sign(context, userKey, + keyPair.privateKey, + null, + "zYwKaKnqS2YzvhXSK+sFjC7FKBoprArqz6LpJ8qe9+g=", + getExpiration())).thenReturn("jws") + + val testObject = + DeviceSigningVerifierCallbackMock(rawContent) + try { + testObject.executeAuthenticate(context, userKey, deviceAuthenticator) + Assert.fail() + } catch (e: Exception) { + Assert.assertTrue(e.message == invalidCustomClaims.message) + Assert.assertTrue(e is DeviceBindingException) + val deviceBindException = e as DeviceBindingException + Assert.assertTrue(deviceBindException.message == invalidCustomClaims.message) + } + } } @@ -172,6 +230,6 @@ class DeviceSigningVerifierCallbackMock constructor(rawContent: String, fragmentActivity: FragmentActivity): UserKey { return UserKey("id1" , "jey", "jey", "kid", DeviceBindingAuthenticationType.NONE, System.currentTimeMillis()) } - }, deviceAuthenticator = authenticator) + }, deviceAuthenticator = authenticator, customClaims = emptyMap()) } } \ No newline at end of file diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DummyNodeListener.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DummyNodeListener.kt index 5beff55a..ac263780 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DummyNodeListener.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/DummyNodeListener.kt @@ -17,6 +17,6 @@ class DummyNodeListener : NodeListener { override fun onException(e: Exception) { } - override fun onCallbackReceived(node: Node?) { + override fun onCallbackReceived(node: Node) { } } \ No newline at end of file diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/ReCaptchaCallbackTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/ReCaptchaCallbackTest.kt index dfca39f9..2835967a 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/ReCaptchaCallbackTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/ReCaptchaCallbackTest.kt @@ -7,6 +7,7 @@ package org.forgerock.android.auth.callback import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import org.junit.Assert @@ -37,4 +38,33 @@ class ReCaptchaCallbackTest { Assert.assertEquals("6Lf3tbYUAAAAAEm78fAOFRKb-n1M67FDtmpczIBK", reCaptchaCallback.reCaptchaSiteKey) } + + @Test + @Throws(JSONException::class) + fun testCustomCaptchaCallback() { + val raw = JSONObject(""" { + "type": "ReCaptchaCallback", + "output": [ + { + "name": "recaptchaSiteKey", + "value": "6Lf3tbYUAAAAAEm78fAOFRKb-n1M67FDtmpczIBK" + } + ], + "input": [ + { + "name": "IDToken1", + "value": "" + } + ] + }""") + val reCaptchaCallback = ReCaptchaCallback(raw, 0) + val expectedValue = "custom_captcha_token" + Assert.assertEquals("6Lf3tbYUAAAAAEm78fAOFRKb-n1M67FDtmpczIBK", + reCaptchaCallback.reCaptchaSiteKey) + reCaptchaCallback.setValue("custom_captcha_token") + val jsonArray: JSONArray = reCaptchaCallback.contentAsJson.get("input") as JSONArray + val inputValue = (jsonArray[0] as JSONObject).get("value") + Assert.assertEquals(expectedValue, + inputValue) + } } \ No newline at end of file diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/SerializableTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/SerializableTest.kt index cc44c48d..a37d85d7 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/SerializableTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/SerializableTest.kt @@ -7,34 +7,38 @@ package org.forgerock.android.auth.callback import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.forgerock.android.auth.Node import org.jeasy.random.EasyRandom import org.jeasy.random.EasyRandomParameters +import org.jeasy.random.TypePredicates import org.jeasy.random.randomizers.text.StringRandomizer import org.junit.Test import org.junit.runner.RunWith import java.io.ByteArrayOutputStream -import java.io.IOException import java.io.ObjectOutputStream //Test to make sure all Callback class are serializable @RunWith(AndroidJUnit4::class) class SerializableTest { @Test - @Throws(IOException::class) fun testCallbackSerializable() { //We generate String for Object type. val parameters = EasyRandomParameters() parameters.randomize(Any::class.java) { StringRandomizer().randomValue } parameters.randomize(Attestation::class.java) { Attestation.Default("1234".toByteArray()) } + parameters.excludeType(TypePredicates.ofType(Node::class.java)) parameters.stringLengthRange(3, 3) val generator = EasyRandom(parameters) for ((_, value) in CallbackFactory.getInstance().callbacks) { val callback = generator.nextObject(value)!! + if (callback is NodeAware) { + val node = Node("authId", "", "", "", "", listOf(callback)) + callback.setNode(node) + } convertToBytes(callback) } } - @Throws(IOException::class) private fun convertToBytes(`object`: Any): ByteArray { ByteArrayOutputStream().use { bos -> ObjectOutputStream(bos).use { out -> diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/BiometricBindingHandlerTests.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/BiometricBindingHandlerTests.kt index 45a5ab76..9932e88a 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/BiometricBindingHandlerTests.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/BiometricBindingHandlerTests.kt @@ -6,19 +6,20 @@ */ package org.forgerock.android.auth.devicebind -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt.AuthenticationCallback import androidx.biometric.BiometricPrompt.AuthenticationResult import androidx.biometric.BiometricPrompt.ERROR_TIMEOUT import androidx.fragment.app.FragmentActivity -import org.forgerock.android.auth.biometric.AuthenticatorType import org.forgerock.android.auth.biometric.BiometricAuth import org.forgerock.android.auth.callback.DeviceBindingAuthenticationType -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.util.concurrent.CountDownLatch @@ -30,90 +31,83 @@ class BiometricBindingHandlerTests { @Test fun testStrongTypeWithBiometricOnly() { - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_STRONG)).thenReturn(true) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_STRONG)).thenReturn(true) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ONLY, fragmentActivity = activity, biometricAuth = biometricAuth) assertTrue(testObject.isSupported()) - verify(biometricAuth).authenticatorType = AuthenticatorType.STRONG } @Test fun testWeakTypeWithBiometricOnly() { - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_WEAK)).thenReturn(true) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_STRONG)).thenReturn(false) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_WEAK)).thenReturn(true) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ONLY, fragmentActivity = activity, biometricAuth = biometricAuth) assertTrue(testObject.isSupported()) - verify(biometricAuth).authenticatorType = AuthenticatorType.WEAK } @Test fun testWeakTypeWithFingerPrint() { + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_STRONG)).thenReturn(false) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_WEAK)).thenReturn(false) whenever(biometricAuth.hasEnrolledWithFingerPrint()).thenReturn(true) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ONLY, fragmentActivity = activity, biometricAuth = biometricAuth) assertTrue(testObject.isSupported()) - verify(biometricAuth).authenticatorType = AuthenticatorType.WEAK } @Test fun testFalseCaseForBiometricOnly() { - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_WEAK)).thenReturn(false) - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_STRONG)).thenReturn(false) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_WEAK)).thenReturn(false) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_STRONG)).thenReturn(false) whenever(biometricAuth.hasEnrolledWithFingerPrint()).thenReturn(false) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ONLY, fragmentActivity = activity, biometricAuth = biometricAuth) assertFalse(testObject.isSupported()) - verify(biometricAuth, never()).authenticatorType = AuthenticatorType.WEAK - verify(biometricAuth, never()).authenticatorType = AuthenticatorType.STRONG } @Test fun testSuccessCaseForBiometricOnlyWhenAnyOneisTrue() { - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_WEAK)).thenReturn(true) - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_STRONG)).thenReturn(false) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_WEAK)).thenReturn(true) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_STRONG)).thenReturn(false) whenever(biometricAuth.hasEnrolledWithFingerPrint()).thenReturn(false) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ONLY, fragmentActivity = activity, biometricAuth = biometricAuth) assertTrue(testObject.isSupported()) - verify(biometricAuth).authenticatorType = AuthenticatorType.WEAK } @Test fun testStrongTypeWithBiometricAndDeviceCredential() { - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)).thenReturn(true) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)).thenReturn(true) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK, fragmentActivity = activity, biometricAuth = biometricAuth) - assertTrue(testObject.isSupported(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL, BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) - verify(biometricAuth).authenticatorType = AuthenticatorType.STRONG + assertTrue(testObject.isSupported(BIOMETRIC_STRONG or DEVICE_CREDENTIAL, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)) } @Test fun testWeakTypeWithBiometricAndDeviceCredential() { - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)).thenReturn(true) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)).thenReturn(true) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK, fragmentActivity = activity, biometricAuth = biometricAuth) - assertTrue(testObject.isSupported(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL, BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) - verify(biometricAuth).authenticatorType = AuthenticatorType.WEAK + assertTrue(testObject.isSupported(BIOMETRIC_STRONG or DEVICE_CREDENTIAL, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)) } @Test fun testWeakTypeFingerPrintWithBiometricAndDeviceCredential() { whenever(biometricAuth.hasEnrolledWithFingerPrint()).thenReturn(true) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)).thenReturn(false) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)).thenReturn(false) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK, fragmentActivity = activity, biometricAuth = biometricAuth) - assertTrue(testObject.isSupported(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL, BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) - verify(biometricAuth).authenticatorType = AuthenticatorType.WEAK + assertTrue(testObject.isSupported(BIOMETRIC_STRONG or DEVICE_CREDENTIAL, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)) } @Test fun testSuccessCaseForBiometricAndDeviceCredentialWhenAnyOneisTrue() { - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)).thenReturn(true) - whenever(biometricAuth.hasBiometricCapability(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)).thenReturn(false) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)).thenReturn(true) + whenever(biometricAuth.hasBiometricCapability(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)).thenReturn(false) whenever(biometricAuth.hasEnrolledWithFingerPrint()).thenReturn(false) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK, fragmentActivity = activity, biometricAuth = biometricAuth) - assertTrue(testObject.isSupported(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL, BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) - verify(biometricAuth).authenticatorType = AuthenticatorType.WEAK + assertTrue(testObject.isSupported(BIOMETRIC_STRONG or DEVICE_CREDENTIAL, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)) } @Test fun testKeyGuardManager() { whenever(biometricAuth.hasDeviceCredential()).thenReturn(true) val testObject = BiometricBindingHandler("title", "subtitle", "description", deviceBindAuthenticationType = DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK, fragmentActivity = activity, biometricAuth = biometricAuth) - assertTrue(testObject.isSupported(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL, BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) - verify(biometricAuth, never()).authenticatorType = AuthenticatorType.WEAK - verify(biometricAuth, never()).authenticatorType = AuthenticatorType.STRONG + assertTrue(testObject.isSupported(BIOMETRIC_STRONG or DEVICE_CREDENTIAL, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)) } @Test diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/DeviceBindAuthenticationTests.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/DeviceBindAuthenticationTests.kt index b811eee1..a40079a8 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/DeviceBindAuthenticationTests.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/DeviceBindAuthenticationTests.kt @@ -8,13 +8,15 @@ package org.forgerock.android.auth.devicebind import android.content.Context import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt.AuthenticationCallback import androidx.biometric.BiometricPrompt.AuthenticationResult +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.nimbusds.jose.JWSObject +import com.nimbusds.jwt.JWTClaimNames import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -25,26 +27,33 @@ import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions import org.assertj.core.data.Offset import org.forgerock.android.auth.CryptoKey +import org.forgerock.android.auth.FRLogger +import org.forgerock.android.auth.Logger +import org.forgerock.android.auth.Logger.Companion.set import org.forgerock.android.auth.callback.Attestation import org.forgerock.android.auth.callback.DeviceBindingAuthenticationType -import org.forgerock.android.auth.devicebind.DeviceBindingErrorStatus.* +import org.forgerock.android.auth.devicebind.DeviceBindingErrorStatus.ClientNotRegistered import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.robolectric.shadows.ShadowLog import java.security.KeyPairGenerator import java.security.PrivateKey import java.security.interfaces.RSAPublicKey import java.time.Instant -import java.util.* +import java.util.Calendar +import java.util.Date class DeviceBindAuthenticationTests { @@ -56,6 +65,28 @@ class DeviceBindAuthenticationTests { @Before fun setUp() { + Logger.setCustomLogger(object : FRLogger { + override fun error(tag: String?, t: Throwable?, message: String?, vararg values: Any?) { + } + + override fun error(tag: String?, message: String?, vararg values: Any?) { + } + + override fun warn(tag: String?, message: String?, vararg values: Any?) { + } + + override fun warn(tag: String?, t: Throwable?, message: String?, vararg values: Any?) { + } + + override fun debug(tag: String?, message: String?, vararg values: Any?) { + } + + override fun info(tag: String?, message: String?, vararg values: Any?) { + } + + override fun network(tag: String?, message: String?, vararg values: Any?) { + } + }) val publicKey = mock() val privateKey = mock() whenever(keyPair.public).thenReturn(publicKey) @@ -73,7 +104,38 @@ class DeviceBindAuthenticationTests { kpg.initialize(2048) val keys = kpg.generateKeyPair() val keyPair = KeyPair(keys.public as RSAPublicKey, keys.private, "jeyAlias") - val output = testObject.sign(context, keyPair, "1234", "3123123123", "77888", getExpiration()) + val output = + testObject.sign(context, keyPair, null, "1234", "3123123123", "77888", getExpiration()) + assertNotNull(output) + val jws = JWSObject.parse(output); + assertEquals("1234", jws.header.keyID) + assertEquals("3123123123", jws.payload.toJSONObject()["sub"]) + Assertions.assertThat(jws.payload.toJSONObject()["nbf"] as Long) + .isCloseTo(Date().time / 1000L, Offset.offset(10)) + Assertions.assertThat(jws.payload.toJSONObject()["iat"] as Long) + .isCloseTo(Date().time / 1000L, Offset.offset(10)) + + assertNotNull(jws.payload.toJSONObject()["exp"]) + } + + @Test + fun testSigningDataWithSignature() { + val testObject = BiometricOnly() + testObject.setBiometricHandler(mockBiometricInterface) + testObject.setKey(cryptoKey) + testObject.isApi30OrAbove = false + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance("RSA") + kpg.initialize(2048) + val keys = kpg.generateKeyPair() + val keyPair = KeyPair(keys.public as RSAPublicKey, keys.private, "jeyAlias") + val output = + testObject.sign(context, + keyPair, + testObject.getSignature(keyPair.privateKey), + "1234", + "3123123123", + "77888", + getExpiration()) assertNotNull(output) val jws = JWSObject.parse(output); assertEquals("1234", jws.header.keyID) @@ -97,7 +159,36 @@ class DeviceBindAuthenticationTests { val keys = kpg.generateKeyPair() val userKey = UserKey("id", "3123123123", "username", "1234", DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK) - val output = testObject.sign(context, userKey, keys.private, "77888", getExpiration() ) + val output = testObject.sign(context, userKey, keys.private, null, "77888", getExpiration()) + assertNotNull(output) + val jws = JWSObject.parse(output); + assertEquals("1234", jws.header.keyID) + assertEquals("3123123123", jws.payload.toJSONObject()["sub"]) + Assertions.assertThat(jws.payload.toJSONObject()["nbf"] as Long) + .isCloseTo(Date().time / 1000L, Offset.offset(10)) + Assertions.assertThat(jws.payload.toJSONObject()["iat"] as Long) + .isCloseTo(Date().time / 1000L, Offset.offset(10)) + + assertNotNull(jws.payload.toJSONObject()["exp"]) + } + + @Test + fun testSigningDataVerifierWithSignature() { + val testObject = BiometricOnly() + testObject.setBiometricHandler(mockBiometricInterface) + testObject.setKey(cryptoKey) + testObject.isApi30OrAbove = false + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance("RSA") + kpg.initialize(2048) + val keys = kpg.generateKeyPair() + val userKey = UserKey("id", "3123123123", "username", "1234", + DeviceBindingAuthenticationType.BIOMETRIC_ALLOW_FALLBACK) + val output = testObject.sign(context, + userKey, + keys.private, + testObject.getSignature(keys.private), + "77888", + getExpiration()) assertNotNull(output) val jws = JWSObject.parse(output); assertEquals("1234", jws.header.keyID) @@ -123,7 +214,7 @@ class DeviceBindAuthenticationTests { val expectedExp = Calendar.getInstance(); expectedExp.add(Calendar.SECOND, 10); val exp = Date.from(Instant.ofEpochSecond(expectedExp.time.time / 1000)); - val output = testObject.sign(context, keyPair, "1234", "3123123123", "77888", exp) + val output = testObject.sign(context, keyPair, null, "1234", "3123123123", "77888", exp) assertNotNull(output) val jws = JWSObject.parse(output); val actualExp = Calendar.getInstance(); @@ -148,7 +239,6 @@ class DeviceBindAuthenticationTests { testObject.generateKeys(context, Attestation.None) - verify(keyBuilder).setUserAuthenticationValidityDurationSeconds(30) verify(keyBuilder).setUserAuthenticationRequired(true) verify(cryptoKey).createKeyPair(keyBuilder.build()) @@ -159,8 +249,6 @@ class DeviceBindAuthenticationTests { testObjectBiometric.generateKeys(context, Attestation.None) - verify(keyBuilder, times(2)).setUserAuthenticationValidityDurationSeconds(30) - verify(keyBuilder, times(2)).setUserAuthenticationRequired(true) verify(cryptoKey, times(2)).createKeyPair(keyBuilder.build()) } @@ -179,8 +267,6 @@ class DeviceBindAuthenticationTests { testObject.isApi30OrAbove = true testObject.generateKeys(context, Attestation.None) - verify(keyBuilder).setUserAuthenticationParameters(30, - KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) verify(keyBuilder).setUserAuthenticationRequired(true) verify(cryptoKey).createKeyPair(keyBuilder.build()) } @@ -200,7 +286,6 @@ class DeviceBindAuthenticationTests { testObject.isApi30OrAbove = true testObject.generateKeys(context, Attestation.None) - verify(keyBuilder).setUserAuthenticationParameters(30, KeyProperties.AUTH_BIOMETRIC_STRONG) verify(keyBuilder).setUserAuthenticationRequired(true) verify(cryptoKey).createKeyPair(keyBuilder.build()) } @@ -265,17 +350,32 @@ class DeviceBindAuthenticationTests { try { val privateKey = mock() val authenticationResult = mock() - whenever(mockBiometricInterface.authenticate(any())).thenAnswer { - (it.arguments[0] as AuthenticationCallback).onAuthenticationSucceeded( - authenticationResult) + whenever(authenticationResult.cryptoObject).thenReturn(null) + var result = false + val biometricHandler = object : BiometricHandler { + override fun isSupported(strongAuthenticators: Int, + weakAuthenticators: Int): Boolean { + return true + } + + override fun isSupportedBiometricStrong(): Boolean { + return true + } + + override fun authenticate(authenticationCallback: AuthenticationCallback, + cryptoObject: BiometricPrompt.CryptoObject?) { + result = true + authenticationCallback.onAuthenticationSucceeded(authenticationResult) + } } + whenever(cryptoKey.getPrivateKey()).thenReturn(privateKey) val testObject = BiometricOnly() - testObject.setBiometricHandler(mockBiometricInterface) + testObject.setBiometricHandler(biometricHandler) testObject.setKey(cryptoKey) testObject.isApi30OrAbove = false testObject.authenticate(context) - verify(mockBiometricInterface).authenticate(any()) + assertTrue(result) } finally { Dispatchers.resetMain() } @@ -289,17 +389,31 @@ class DeviceBindAuthenticationTests { try { val privateKey = mock() val authenticationResult = mock() - whenever(mockBiometricInterface.authenticate(any())).thenAnswer { - (it.arguments[0] as AuthenticationCallback).onAuthenticationSucceeded( - authenticationResult) + whenever(authenticationResult.cryptoObject).thenReturn(null) + var result = false + val biometricHandler = object : BiometricHandler { + override fun isSupported(strongAuthenticators: Int, + weakAuthenticators: Int): Boolean { + return true + } + + override fun isSupportedBiometricStrong(): Boolean { + return true + } + + override fun authenticate(authenticationCallback: AuthenticationCallback, + cryptoObject: BiometricPrompt.CryptoObject?) { + result = true + authenticationCallback.onAuthenticationSucceeded(authenticationResult) + } } whenever(cryptoKey.getPrivateKey()).thenReturn(privateKey) val testObject = BiometricAndDeviceCredential() - testObject.setBiometricHandler(mockBiometricInterface) + testObject.setBiometricHandler(biometricHandler) testObject.setKey(cryptoKey) testObject.isApi30OrAbove = false testObject.authenticate(context) - verify(mockBiometricInterface).authenticate(any()) + assertTrue(result) } finally { Dispatchers.resetMain() } @@ -326,7 +440,7 @@ class DeviceBindAuthenticationTests { testObject.setKey(cryptoKey) testObject.isApi30OrAbove = false assertEquals(ClientNotRegistered(), testObject.authenticate(context)) - verify(mockBiometricInterface, never()).authenticate(any()) + verify(mockBiometricInterface, never()).authenticate(any(), any()) } finally { Dispatchers.resetMain() } @@ -338,5 +452,29 @@ class DeviceBindAuthenticationTests { return date.time; } + @Test + fun testValidateCustomClaimsForValidClaims() { + val testObject = None() + assertTrue(testObject.validateCustomClaims(customClaims = mapOf("name" to "demo", "email_verified" to true))) + } + + @Test + fun testValidateCustomClaimsForInvalidClaims() { + val testObject = None() + + assertFalse(testObject.validateCustomClaims(mapOf(JWTClaimNames.SUBJECT to "demo"))) + assertFalse(testObject.validateCustomClaims(mapOf(JWTClaimNames.EXPIRATION_TIME to "demo"))) + assertFalse(testObject.validateCustomClaims(mapOf(JWTClaimNames.ISSUED_AT to "demo"))) + assertFalse(testObject.validateCustomClaims(mapOf(JWTClaimNames.NOT_BEFORE to "demo"))) + assertFalse(testObject.validateCustomClaims(mapOf(JWTClaimNames.ISSUER to "demo"))) + assertFalse(testObject.validateCustomClaims(mapOf("challenge" to "demo"))) + assertFalse(testObject.validateCustomClaims(mapOf(JWTClaimNames.ISSUER to "demo", JWTClaimNames.EXPIRATION_TIME to Date()))) + } + + @Test + fun testValidateCustomClaimsForEmptyClaims() { + val testObject = None() + assertTrue(testObject.validateCustomClaims(emptyMap())) + } } \ No newline at end of file diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/RSASASignatureSignerTests.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/RSASASignatureSignerTests.kt new file mode 100644 index 00000000..2868d9ea --- /dev/null +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/devicebind/RSASASignatureSignerTests.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.devicebind + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSObject +import com.nimbusds.jose.Payload +import com.nimbusds.jose.crypto.RSASSAVerifier +import com.nimbusds.jose.crypto.impl.RSASSAProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.interfaces.RSAPublicKey + + +class RSASASignatureSignerTests { + + private lateinit var keys: KeyPair + private lateinit var jwsObject: JWSObject + private lateinit var signature: Signature + + @Before + fun setUp() { + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance("RSA") + kpg.initialize(2048) + keys = kpg.generateKeyPair() + val header: JWSHeader = JWSHeader.Builder(JWSAlgorithm.RS256).build() + assertThat(header.algorithm).isEqualTo(JWSAlgorithm.RS256) + val payload = Payload("test") + jwsObject = JWSObject(header, payload) + signature = object : RSASSAProvider() { + }.signature(JWSAlgorithm.RS256, keys.private) + } + + @Test + fun `test sign and verify with signature`() { + assertThat(jwsObject.state).isEqualTo(JWSObject.State.UNSIGNED) + val signer = RSASASignatureSigner(signature) + jwsObject.sign(signer) + val verifier = RSASSAVerifier(keys.public as RSAPublicKey) + assertThat(jwsObject.verify(verifier)).isTrue() + assertThat(jwsObject.state).isEqualTo(JWSObject.State.VERIFIED) + } + + @Test + fun `test sign and verify with invalid public key`() { + assertThat(jwsObject.state).isEqualTo(JWSObject.State.UNSIGNED) + val signer = RSASASignatureSigner(signature) + jwsObject.sign(signer) + + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance("RSA") + kpg.initialize(2048) + val anotherKey = kpg.generateKeyPair() + + val verifier = RSASSAVerifier(anotherKey.public as RSAPublicKey) + assertThat(jwsObject.verify(verifier)).isFalse() + assertThat(jwsObject.state).isEqualTo(JWSObject.State.SIGNED) + } +} \ No newline at end of file diff --git a/forgerock-authenticator/build.gradle b/forgerock-authenticator/build.gradle index 26a86095..11ce6c3d 100644 --- a/forgerock-authenticator/build.gradle +++ b/forgerock-authenticator/build.gradle @@ -10,6 +10,7 @@ apply plugin: "com.adarshr.test-logger" apply plugin: 'maven-publish' apply plugin: 'signing' apply plugin: 'kotlin-android' +// We cannot use kdoc for this project due to Lombak ,so need to add this dokka plugin here. apply plugin: 'org.jetbrains.dokka' android { @@ -86,14 +87,6 @@ apply from: '../config/publish.gradle' /** * Dependencies */ -configurations.all { - resolutionStrategy { - //Due to Vulnerability [CVE-2023-3635] CWE-681: Incorrect Conversion between Numeric Types - //on version < 3.4.0, this library is depended by okhttp, when okhttp upgrade, this needs - //to be reviewed - force 'com.squareup.okio:okio:3.4.0' - } -} dependencies { api project(':forgerock-core') implementation fileTree(dir: 'libs', include: ['*.jar']) diff --git a/forgerock-authenticator/src/main/java/org/forgerock/android/auth/PushNotification.java b/forgerock-authenticator/src/main/java/org/forgerock/android/auth/PushNotification.java index 61d27e2c..d5c3960d 100644 --- a/forgerock-authenticator/src/main/java/org/forgerock/android/auth/PushNotification.java +++ b/forgerock-authenticator/src/main/java/org/forgerock/android/auth/PushNotification.java @@ -382,7 +382,7 @@ public void onAuthenticationFailed() { //Ignore to allow fingerprint retry } }); - biometricAuth.authenticate(); + biometricAuth.authenticate(null); } else { listener.onException(new PushMechanismException("Error processing the Push " + "Authentication request. This method cannot be used to process notification " + diff --git a/forgerock-core/build.gradle b/forgerock-core/build.gradle index 5c0b4336..eff3042b 100644 --- a/forgerock-core/build.gradle +++ b/forgerock-core/build.gradle @@ -10,7 +10,6 @@ apply plugin: "com.adarshr.test-logger" apply plugin: 'maven-publish' apply plugin: 'signing' apply plugin: 'kotlin-android' -apply plugin: 'org.jetbrains.dokka' android { namespace 'org.forgerock.android.core' @@ -55,25 +54,16 @@ android { exclude '**/*TestSuite*' } } + + kotlinOptions { + freeCompilerArgs = ['-Xjvm-default=all'] + } } apply from: '../config/logger.gradle' apply from: '../config/kdoc.gradle' apply from: '../config/publish.gradle' -configurations.all { - //Due to this https://github.com/powermock/powermock/issues/1125, we have to keep using an - //older version of mockito until mockito release a fix - resolutionStrategy { - force 'org.mockito:mockito-core:3.12.4' - // this is for the mockwebserver - force 'org.bouncycastle:bcprov-jdk15on:1.68' - //Due to Vulnerability [CVE-2023-3635] CWE-681: Incorrect Conversion between Numeric Types - //on version < 3.4.0, this library is depended by okhttp, when okhttp upgrade, this needs - //to be reviewed - force 'com.squareup.okio:okio:3.4.0' - } -} /** * Dependencies */ @@ -98,7 +88,7 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'com.squareup.okhttp:mockwebserver:2.7.5' androidTestImplementation 'commons-io:commons-io:2.6' - androidTestImplementation 'com.android.support.test:rules:1.0.2' + androidTestImplementation 'androidx.test:rules:1.5.0' //Do not update to the latest library, Only 2.x compatible with Android M and below. androidTestImplementation 'org.assertj:assertj-core:2.9.1' diff --git a/forgerock-core/src/main/java/org/forgerock/android/auth/CookieInterceptor.kt b/forgerock-core/src/main/java/org/forgerock/android/auth/CookieInterceptor.kt new file mode 100644 index 00000000..02ab991d --- /dev/null +++ b/forgerock-core/src/main/java/org/forgerock/android/auth/CookieInterceptor.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth + +import okhttp3.Cookie + +/** + * Observes, modifies outgoing request cookie header from the SDK. + * Interceptors can be used to add, remove, or transform cookie headers on the request. + */ +interface CookieInterceptor { + + /** + * Intercepts outgoing request cookie header from the SDK. + * + * @param cookies The original outgoing cookies + * @return The Updated Cookies + */ + fun intercept(cookies: List): List +} \ No newline at end of file diff --git a/forgerock-core/src/main/java/org/forgerock/android/auth/CryptoKey.kt b/forgerock-core/src/main/java/org/forgerock/android/auth/CryptoKey.kt index f88cb6e3..b7d24290 100644 --- a/forgerock-core/src/main/java/org/forgerock/android/auth/CryptoKey.kt +++ b/forgerock-core/src/main/java/org/forgerock/android/auth/CryptoKey.kt @@ -30,7 +30,7 @@ class CryptoKey(private var keyId: String) { //For hashing the keyId private val hashingAlgorithm = "SHA-256" val keySize = 2048 - val timeout = 60 + val timeout = 5 private val androidKeyStore = "AndroidKeyStore" private val encryptionBlockMode = KeyProperties.BLOCK_MODE_ECB private val encryptionPadding = KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1 @@ -45,7 +45,9 @@ class CryptoKey(private var keyId: String) { */ fun keyBuilder(): KeyGenParameterSpec.Builder { return KeyGenParameterSpec.Builder(keyAlias, purpose) - .setDigests(KeyProperties.DIGEST_SHA512).setKeySize(keySize) + .setDigests(KeyProperties.DIGEST_SHA512, + KeyProperties.DIGEST_SHA384, + KeyProperties.DIGEST_SHA256).setKeySize(keySize) .setSignaturePaddings(signaturePadding).setBlockModes(encryptionBlockMode) .setEncryptionPaddings(encryptionPadding) } @@ -79,7 +81,7 @@ class CryptoKey(private var keyId: String) { /** * Get the Certificate chain */ - fun getCertificateChain(): Array{ + fun getCertificateChain(): Array { val keyStore: KeyStore = getKeyStore() return keyStore.getCertificateChain(keyAlias) } diff --git a/forgerock-core/src/main/java/org/forgerock/android/auth/OkHttpCookieInterceptor.kt b/forgerock-core/src/main/java/org/forgerock/android/auth/OkHttpCookieInterceptor.kt new file mode 100644 index 00000000..026741db --- /dev/null +++ b/forgerock-core/src/main/java/org/forgerock/android/auth/OkHttpCookieInterceptor.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth + +import okhttp3.Cookie + +/** + * Interceptor to intercept Http Request Cookie header + * and invoke registered [RequestInterceptor] + */ +interface OkHttpCookieInterceptor : CookieInterceptor { + + override fun intercept(cookies: List): List { + var updatedCookies = cookies + RequestInterceptorRegistry.getInstance().requestInterceptors?.let { requestInterceptors -> + requestInterceptors.filterIsInstance() + .forEach { + updatedCookies = it.intercept(updatedCookies) + } + } + return updatedCookies; + } +} diff --git a/forgerock-core/src/main/java/org/forgerock/android/auth/RequestInterceptorRegistry.java b/forgerock-core/src/main/java/org/forgerock/android/auth/RequestInterceptorRegistry.java index 3bbd9ded..bc876163 100644 --- a/forgerock-core/src/main/java/org/forgerock/android/auth/RequestInterceptorRegistry.java +++ b/forgerock-core/src/main/java/org/forgerock/android/auth/RequestInterceptorRegistry.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 ForgeRock. All rights reserved. + * Copyright (c) 2020 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -7,8 +7,6 @@ package org.forgerock.android.auth; -import lombok.Getter; - /** * Registry to manage {@link RequestInterceptor} */ @@ -16,7 +14,6 @@ public class RequestInterceptorRegistry { private static final RequestInterceptorRegistry INSTANCE = new RequestInterceptorRegistry(); - @Getter private RequestInterceptor[] requestInterceptors; private RequestInterceptorRegistry() { @@ -40,4 +37,7 @@ public void register(RequestInterceptor... requestInterceptors) { this.requestInterceptors = requestInterceptors; } + public RequestInterceptor[] getRequestInterceptors() { + return this.requestInterceptors; + } } diff --git a/forgerock-core/src/main/java/org/forgerock/android/auth/SecuredSharedPreferences.java b/forgerock-core/src/main/java/org/forgerock/android/auth/SecuredSharedPreferences.java index 31fc2487..78875504 100644 --- a/forgerock-core/src/main/java/org/forgerock/android/auth/SecuredSharedPreferences.java +++ b/forgerock-core/src/main/java/org/forgerock/android/auth/SecuredSharedPreferences.java @@ -212,7 +212,7 @@ private String decrypt(@NonNull String data) { return new String(encryptor.decrypt(Base64.decode(data, Base64.DEFAULT))); } catch (EncryptionException e) { //Failed to decrypt the data, reset the encryptor - Logger.warn(TAG, "Failed to decrypt the data."); + Logger.warn(TAG, "Failed to decrypt the data.", e); edit().clear().commit(); return null; } @@ -222,14 +222,17 @@ private String encrypt(byte[] value, boolean retry) { try { return Base64.encodeToString(encryptor.encrypt(value), Base64.DEFAULT); } catch (Exception e) { + Logger.warn(TAG, "Failed to encrypt data. retrying...", e); try { encryptor.reset(); if (retry) { return encrypt(value, false); } else { + Logger.error(TAG, "Failed to encrypt data after retry.", e); throw new RuntimeException(e); } } catch (Exception ex) { + Logger.error(TAG, "Failed to encrypt data.", e); throw new RuntimeException(ex); } } diff --git a/forgerock-core/src/main/java/org/forgerock/android/auth/biometric/BiometricAuth.kt b/forgerock-core/src/main/java/org/forgerock/android/auth/biometric/BiometricAuth.kt index 6595ddab..87be236b 100644 --- a/forgerock-core/src/main/java/org/forgerock/android/auth/biometric/BiometricAuth.kt +++ b/forgerock-core/src/main/java/org/forgerock/android/auth/biometric/BiometricAuth.kt @@ -15,8 +15,12 @@ import android.hardware.fingerprint.FingerprintManager import android.os.Build import androidx.annotation.RestrictTo import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt.AuthenticationCallback +import androidx.biometric.BiometricPrompt.CryptoObject import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import org.forgerock.android.auth.Logger.Companion.debug @@ -34,7 +38,6 @@ import org.forgerock.android.auth.Logger.Companion.debug * @param activity the activity of the client application that will host the prompt. * @param biometricAuthListener listener for receiving the biometric authentication result. * @param description the description to be displayed on the prompt. - * @param authenticatorType The type of biometric authenticator to be displayed. */ class BiometricAuth @JvmOverloads constructor( /** @@ -67,12 +70,8 @@ class BiometricAuth @JvmOverloads constructor( */ private val description: String? = null, - /** - * The type of authenticator to be displayed. - * @return the authenticatorType as Int. - */ - var authenticatorType: AuthenticatorType = AuthenticatorType.WEAK ) { + private var biometricManager: BiometricManager? = null private var fingerprintManager: FingerprintManager? = null @@ -92,35 +91,37 @@ class BiometricAuth @JvmOverloads constructor( errorType, biometricErrorMessage) } - private fun tryDisplayBiometricOnlyPrompt() { + private fun tryDisplayBiometricOnlyPrompt(cryptoObject: CryptoObject?) { if (hasBiometricCapability()) { - initBiometricAuthentication() + initBiometricAuthentication(cryptoObject) } else { handleError("allowDeviceCredentials is set to false, but no biometric " + - "hardware found or enrolled." ,"It requires " + - "biometric authentication. No biometric hardware found or enrolled.", BiometricPrompt.ERROR_NO_BIOMETRICS) + "hardware found or enrolled.", + "It requires " + + "biometric authentication. No biometric hardware found or enrolled.", + BiometricPrompt.ERROR_NO_BIOMETRICS) } } /* * Starts authentication process. */ - fun authenticate() { + fun authenticate(cryptoObject: CryptoObject? = null) { // if biometric only, try biometric prompt if (!allowDeviceCredentials) { - tryDisplayBiometricOnlyPrompt() + tryDisplayBiometricOnlyPrompt(cryptoObject) return } // API 29 and above, use BiometricPrompt if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - initBiometricAuthentication() + initBiometricAuthentication(cryptoObject) return } // API 23 - 28, check enrollment with FingerprintManager once BiometricPrompt might not work - if(hasEnrolledWithFingerPrint()) { - initBiometricAuthentication() + if (hasEnrolledWithFingerPrint()) { + initBiometricAuthentication(cryptoObject) return } @@ -129,9 +130,11 @@ class BiometricAuth @JvmOverloads constructor( initDeviceCredentialAuthentication() } else { handleError("This device does not support required security features." + - " No Biometric, device PIN, pattern, or password registered." ,"This device does " + - "not support required security features. No Biometric, device PIN, pattern, " + - "or password registered.", BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL) + " No Biometric, device PIN, pattern, or password registered.", + "This device does " + + "not support required security features. No Biometric, device PIN, pattern, " + + "or password registered.", + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL) } } @@ -176,23 +179,23 @@ class BiometricAuth @JvmOverloads constructor( .setSubtitle(subtitle ?: "Log in using your biometric credential") val authenticators: Int if (allowDeviceCredentials) { - authenticators = if(authenticatorType == AuthenticatorType.WEAK) { - BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL + authenticators = if(hasBiometricCapability(BIOMETRIC_STRONG or DEVICE_CREDENTIAL )) { + BIOMETRIC_STRONG or DEVICE_CREDENTIAL } else { - BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL + BIOMETRIC_WEAK or DEVICE_CREDENTIAL } } else { - authenticators = if(authenticatorType == AuthenticatorType.WEAK) { - BiometricManager.Authenticators.BIOMETRIC_WEAK + authenticators = if(hasBiometricCapability(BIOMETRIC_STRONG)) { + BIOMETRIC_STRONG } else { - BiometricManager.Authenticators.BIOMETRIC_STRONG + BIOMETRIC_WEAK } builder.setNegativeButtonText("Cancel") } description?.let { builder.setDescription(it) } - builder.setAllowedAuthenticators(authenticators) + builder.setAllowedAuthenticators(authenticators) promptInfo = builder.build() return biometricPrompt } @@ -200,15 +203,17 @@ class BiometricAuth @JvmOverloads constructor( private fun hasBiometricCapability(): Boolean { if (biometricManager == null) return false val canAuthenticate = - biometricManager?.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) + biometricManager?.canAuthenticate(BIOMETRIC_WEAK) return canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS } - private fun initBiometricAuthentication() { + private fun initBiometricAuthentication(cryptoObject: CryptoObject?) { val biometricPrompt = initBiometricPrompt() - promptInfo?.let { + promptInfo?.let { promptInfo -> activity.runOnUiThread() { - biometricPrompt.authenticate(it) + cryptoObject?.let { + biometricPrompt.authenticate(promptInfo, it) + } ?: biometricPrompt.authenticate(promptInfo) } } } @@ -221,13 +226,14 @@ class BiometricAuth @JvmOverloads constructor( private val TAG = BiometricAuth::class.java.simpleName @JvmStatic - fun isBiometricAvailable(applicationContext: Context) : Boolean{ + fun isBiometricAvailable(applicationContext: Context): Boolean { var canAuthenticate = true if (Build.VERSION.SDK_INT < 28) { - val keyguardManager : KeyguardManager = applicationContext.getSystemService(KEYGUARD_SERVICE) as KeyguardManager - val packageManager : PackageManager = applicationContext.packageManager + val keyguardManager: KeyguardManager = + applicationContext.getSystemService(KEYGUARD_SERVICE) as KeyguardManager + val packageManager: PackageManager = applicationContext.packageManager // Check if Fingerprint Sensor is supported - if(packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT).not()) { + if (packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT).not()) { canAuthenticate = false } // Check if lock screen security is enabled in Settings @@ -241,8 +247,8 @@ class BiometricAuth @JvmOverloads constructor( } } else { // Check if biometric is supported - val biometricManager : BiometricManager = BiometricManager.from(applicationContext) - if(biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) != BiometricManager.BIOMETRIC_SUCCESS){ + val biometricManager: BiometricManager = BiometricManager.from(applicationContext) + if (biometricManager.canAuthenticate(BIOMETRIC_WEAK) != BiometricManager.BIOMETRIC_SUCCESS) { canAuthenticate = false } // Check if Fingerprint Authentication Permission was granted diff --git a/forgerock-core/src/test/java/org/forgerock/android/auth/RequestInterceptorTest.java b/forgerock-core/src/test/java/org/forgerock/android/auth/RequestInterceptorTest.java index 86ccee7d..5a80bdad 100644 --- a/forgerock-core/src/test/java/org/forgerock/android/auth/RequestInterceptorTest.java +++ b/forgerock-core/src/test/java/org/forgerock/android/auth/RequestInterceptorTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 - 2022 ForgeRock. All rights reserved. + * Copyright (c) 2020 - 2023 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -8,12 +8,13 @@ package org.forgerock.android.auth; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static java.util.Collections.singletonList; import android.net.Uri; +import androidx.annotation.NonNull; + import com.squareup.okhttp.mockwebserver.MockResponse; import com.squareup.okhttp.mockwebserver.MockWebServer; import com.squareup.okhttp.mockwebserver.RecordedRequest; @@ -28,12 +29,18 @@ import org.robolectric.RobolectricTestRunner; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; import java.util.Iterator; +import java.util.List; import java.util.concurrent.CountDownLatch; import kotlin.Pair; import okhttp3.Call; import okhttp3.Callback; +import okhttp3.Cookie; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.RequestBody; @@ -437,6 +444,41 @@ public void testRegistry() { assertThat(RequestInterceptorRegistry.getInstance().getRequestInterceptors().length).isEqualTo(2); } + @Test + public void testCookieIntercept() throws InterruptedException { + + RequestInterceptorRegistry.getInstance().register((CustomCookieInterceptor) httpUrl -> { + List cookies = new ArrayList<>(); + cookies.add(new Cookie.Builder().domain("localhost").name("test").value("testValue").build()); + return cookies; + }); + + NetworkConfig networkConfig = NetworkConfig.networkBuilder() + .host(server.getHostName()) + .cookieJarSupplier(() -> new CustomCookieJar() {}).build(); + okhttp3.Request request = new okhttp3.Request.Builder() + .url(getUrl()) + .get() + .build(); + send(networkConfig, request); + RecordedRequest recordedRequest = server.takeRequest(); + assertThat(recordedRequest.getHeader("Cookie")).isEqualTo("test=testValue"); + } + + private interface CustomCookieJar extends CookieJar, OkHttpCookieInterceptor { + @NonNull + @Override + default List loadForRequest(@NonNull HttpUrl httpUrl) { + return intercept(Collections.emptyList()); + } + + @Override + default void saveFromResponse(@NonNull HttpUrl httpUrl, @NonNull List list) { + } + } + + + private void send(NetworkConfig networkConfig, okhttp3.Request request) throws InterruptedException { OkHttpClient client = OkHttpClientProvider.getInstance().lookup(networkConfig); CountDownLatch countDownLatch = new CountDownLatch(1); @@ -454,6 +496,14 @@ public void onResponse(@NotNull Call call, @NotNull Response response) throws IO }); countDownLatch.await(); } + private interface CustomCookieInterceptor extends FRRequestInterceptor, CookieInterceptor { + @NonNull + @Override + default Request intercept(@NonNull Request request, Action tag) { + return request; + } + } + } diff --git a/gradle.properties b/gradle.properties index 48db9398..47560012 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,11 +21,10 @@ org.gradle.jvmargs=-Xmx1536m # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official android.useAndroidX=true -android.enableJetifier=true GROUP=org.forgerock -VERSION=4.2.0 -VERSION_CODE=18 +VERSION=4.3.0 +VERSION_CODE=19 android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false android.nonFinalResIds=false \ No newline at end of file diff --git a/samples/app/build.gradle b/samples/app/build.gradle index 286a9983..cf9d756f 100644 --- a/samples/app/build.gradle +++ b/samples/app/build.gradle @@ -63,15 +63,6 @@ repositories { } } -configurations.all { - resolutionStrategy { - force 'com.google.android.gms:play-services-basement:18.1.0' - //Due to Vulnerability [CVE-2023-3635] CWE-681: Incorrect Conversion between Numeric Types - //on version < 3.4.0, this library is depended by okhttp, when okhttp upgrade, this needs - //to be reviewed - force 'com.squareup.okio:okio:3.4.0' - } -} dependencies { def composeBom = platform('androidx.compose:compose-bom:2022.10.00') @@ -80,7 +71,7 @@ dependencies { //SDK implementation project(':forgerock-auth') - //implementation 'org.forgerock:forgerock-auth:4.2.0-SNAPSHOT' + //implementation 'org.forgerock:forgerock-auth:4.2.0' //Device Binding + JWT + Application Pin implementation 'com.madgag.spongycastle:bcpkix-jdk15on:1.58.0.0' //Application PIN implementation 'androidx.security:security-crypto:1.1.0-alpha05' @@ -92,6 +83,8 @@ dependencies { //Centralize Login implementation 'net.openid:appauth:0.11.1' + //Captcha + implementation 'com.google.android.gms:play-services-safetynet:18.0.1' //Device Profile to retrieve Location implementation "com.google.accompanist:accompanist-permissions:0.30.1" @@ -101,7 +94,7 @@ dependencies { implementation 'com.facebook.android:facebook-login:16.0.0' //For App integrity - implementation 'com.google.android.play:integrity:1.2.0' + implementation 'com.google.android.play:integrity:1.3.0' //Capture Location for Device Profile implementation 'com.google.android.gms:play-services-location:21.0.1' diff --git a/samples/app/src/main/AndroidManifest.xml b/samples/app/src/main/AndroidManifest.xml index f7292a98..d7066d23 100644 --- a/samples/app/src/main/AndroidManifest.xml +++ b/samples/app/src/main/AndroidManifest.xml @@ -27,10 +27,10 @@ @@ -39,7 +39,8 @@ + android:exported="true" + android:theme="@style/Theme.App.Starting"> diff --git a/samples/app/src/main/java/com/example/app/AppDrawer.kt b/samples/app/src/main/java/com/example/app/AppDrawer.kt index 3b0e1020..143e3da8 100644 --- a/samples/app/src/main/java/com/example/app/AppDrawer.kt +++ b/samples/app/src/main/java/com/example/app/AppDrawer.kt @@ -9,10 +9,9 @@ package com.example.app import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -25,9 +24,9 @@ import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.OnDeviceTraining import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.RocketLaunch +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItemDefaults @@ -35,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.example.app.Destinations.CENTRALIZE_ROUTE @@ -44,6 +44,7 @@ import com.example.app.Destinations.IG import com.example.app.Destinations.LAUNCH_ROUTE import com.example.app.Destinations.MANAGE_USER_KEYS import com.example.app.Destinations.MANAGE_WEBAUTHN_KEYS +import com.example.app.Destinations.SETTING import com.example.app.Destinations.TOKEN_ROUTE @OptIn(ExperimentalMaterial3Api::class) @@ -59,7 +60,7 @@ fun AppDrawer( modifier = Modifier .verticalScroll(scroll)) { Logo( - modifier = Modifier.padding(horizontal = 28.dp, vertical = 48.dp) + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) ) NavigationDrawerItem( label = { Text("Environment") }, @@ -135,6 +136,16 @@ fun AppDrawer( }, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) ) + NavigationDrawerItem( + label = { Text("Setting") }, + selected = false, + icon = { Icon(Icons.Filled.Settings, null) }, + onClick = { + navigateTo(SETTING); + closeDrawer() + }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) NavigationDrawerItem( label = { Text("Logout") }, selected = false, @@ -150,17 +161,19 @@ fun AppDrawer( } @Composable -private fun Logo(modifier: Modifier = Modifier) { +private fun Logo(modifier: Modifier) { Row(modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.primary) + .background(colorResource(id = R.color.black)) .then(modifier)) { Icon( - painterResource(R.drawable.forgerock), - contentDescription = null + painterResource(R.drawable.ping_logo), + contentDescription = null, + modifier = Modifier + .height(100.dp).padding(8.dp) + .then(modifier), + tint = Color.Unspecified, ) - Spacer(Modifier.width(8.dp)) - Text(text = "ForgeRock") } } \ No newline at end of file diff --git a/samples/app/src/main/java/com/example/app/AppNavHost.kt b/samples/app/src/main/java/com/example/app/AppNavHost.kt index b6856193..e2439b3b 100644 --- a/samples/app/src/main/java/com/example/app/AppNavHost.kt +++ b/samples/app/src/main/java/com/example/app/AppNavHost.kt @@ -27,6 +27,8 @@ import com.example.app.ig.IGViewModel import com.example.app.journey.Journey import com.example.app.journey.JourneyRoute import com.example.app.journey.JourneyViewModel +import com.example.app.setting.SettingRoute +import com.example.app.setting.SettingViewModel import com.example.app.token.Token import com.example.app.token.TokenViewModel import com.example.app.userkeys.UserKeysRoute @@ -102,7 +104,7 @@ fun AppNavHost(navController: NavHostController, factory = JourneyViewModel.factory(LocalContext.current, this) ) Journey(journeyViewModel, onSuccess = { - navController.navigate(Destinations.USER_SESSION) + navController.navigate(Destinations.USER_SESSION) }) { } } @@ -112,7 +114,11 @@ fun AppNavHost(navController: NavHostController, val userProfileViewModel = viewModel() UserProfile(userProfileViewModel) } - - } + composable(Destinations.SETTING) { + val settingViewModel = viewModel( + factory = SettingViewModel.factory(LocalContext.current)) + SettingRoute(settingViewModel) + } + } } \ No newline at end of file diff --git a/samples/app/src/main/java/com/example/app/Destinations.kt b/samples/app/src/main/java/com/example/app/Destinations.kt index 8da9d5ab..910b1a34 100644 --- a/samples/app/src/main/java/com/example/app/Destinations.kt +++ b/samples/app/src/main/java/com/example/app/Destinations.kt @@ -16,6 +16,7 @@ object Destinations { const val MANAGE_USER_KEYS = "User Keys" const val IG = "IG protected endpoint" const val DEVICE_PROFILE = "Device Profile" + const val SETTING = "Setting" const val CENTRALIZE_ROUTE = "Centralize Login" const val USER_SESSION = "User Session" } diff --git a/samples/app/src/main/java/com/example/app/MainViewModel.kt b/samples/app/src/main/java/com/example/app/MainViewModel.kt index a1aef3e9..6f877ba9 100644 --- a/samples/app/src/main/java/com/example/app/MainViewModel.kt +++ b/samples/app/src/main/java/com/example/app/MainViewModel.kt @@ -9,8 +9,11 @@ package com.example.app import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlin.time.DurationUnit +import kotlin.time.toDuration class MainViewModel : ViewModel() { @@ -19,7 +22,9 @@ class MainViewModel : ViewModel() { init { viewModelScope.launch { + delay(2.toDuration(DurationUnit.SECONDS)) isLoading.value = false + } } } \ No newline at end of file diff --git a/samples/app/src/main/java/com/example/app/callback/AppIntegrityCallback.kt b/samples/app/src/main/java/com/example/app/callback/AppIntegrityCallback.kt new file mode 100644 index 00000000..953e08a2 --- /dev/null +++ b/samples/app/src/main/java/com/example/app/callback/AppIntegrityCallback.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.example.app.callback + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.google.android.play.core.integrity.IntegrityServiceException +import org.forgerock.android.auth.Logger +import org.forgerock.android.auth.callback.AppIntegrityCallback + +@Composable +fun AppIntegrityCallback(callback: AppIntegrityCallback, onCompleted: () -> Unit) { + + val currentOnCompleted by rememberUpdatedState(onCompleted) + val context = LocalContext.current + + Column(modifier = Modifier + .padding(8.dp) + .fillMaxHeight() + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "Checking App Integrity...") + Spacer(Modifier.height(8.dp)) + CircularProgressIndicator() + + LaunchedEffect(true) { + try { + callback.requestIntegrityToken(context) + //callback.clearCache() + } catch (e: IntegrityServiceException) { + Logger.error("AppIntegrityCallback", e, e.message) + /* + when (e.errorCode) { + //We can set different error with different condition + IntegrityErrorCode.TOO_MANY_REQUESTS, + IntegrityErrorCode.GOOGLE_SERVER_UNAVAILABLE, + IntegrityErrorCode.CLIENT_TRANSIENT_ERROR, + IntegrityErrorCode.INTERNAL_ERROR -> callback.setClientError("Retry") + } + */ + } catch (e: Exception) { + Logger.error("AppIntegrityCallback", e, e.message) + } + currentOnCompleted() + } + } +} + diff --git a/samples/app/src/main/java/com/example/app/callback/DeviceSigningVerifierCallback.kt b/samples/app/src/main/java/com/example/app/callback/DeviceSigningVerifierCallback.kt index 9fe17b84..e870a8d2 100644 --- a/samples/app/src/main/java/com/example/app/callback/DeviceSigningVerifierCallback.kt +++ b/samples/app/src/main/java/com/example/app/callback/DeviceSigningVerifierCallback.kt @@ -129,7 +129,7 @@ suspend fun sign(context: Context, callback: DeviceSigningVerifierCallback) { repeat(3) { // try { //Show how to use custom App Pin Dialog with Compose - callback.sign(context) { type -> + callback.sign(context, mapOf("os" to "android")) { type -> if (type == DeviceBindingAuthenticationType.APPLICATION_PIN) { CustomAppPinDeviceAuthenticator() } else { diff --git a/samples/app/src/main/java/com/example/app/callback/ReCaptchaCallback.kt b/samples/app/src/main/java/com/example/app/callback/ReCaptchaCallback.kt new file mode 100644 index 00000000..a66768ab --- /dev/null +++ b/samples/app/src/main/java/com/example/app/callback/ReCaptchaCallback.kt @@ -0,0 +1,66 @@ +package com.example.app.callback + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import org.forgerock.android.auth.FRListener +import org.forgerock.android.auth.Logger +import org.forgerock.android.auth.Node +import org.forgerock.android.auth.callback.ReCaptchaCallback + +@Composable +fun ReCaptchaCallback(callback: ReCaptchaCallback, node: Node, + onCompleted: () -> Unit) { + + + val currentOnCompleted by rememberUpdatedState(onCompleted) + val context = LocalContext.current + + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) { + + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxHeight() + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "Launching Recaptcha...") + Spacer(Modifier.height(8.dp)) + CircularProgressIndicator() + + LaunchedEffect(true) { + callback.proceed(context, object : FRListener { + override fun onSuccess(result: Void?) { + currentOnCompleted() + } + + override fun onException(e: Exception) { + currentOnCompleted() + } + }) + } + } + } +} diff --git a/samples/app/src/main/java/com/example/app/env/EnvViewModel.kt b/samples/app/src/main/java/com/example/app/env/EnvViewModel.kt index 0ee00c96..7d7f1968 100644 --- a/samples/app/src/main/java/com/example/app/env/EnvViewModel.kt +++ b/samples/app/src/main/java/com/example/app/env/EnvViewModel.kt @@ -22,7 +22,7 @@ class EnvViewModel : ViewModel() { val localhost = FROptionsBuilder.build { server { - url = "http://192.168.86.30:8080/openam" + url = "http://192.168.86.248:8080/openam" realm = "root" cookieName = "iPlanetDirectoryPro" timeout = 50 @@ -38,8 +38,8 @@ class EnvViewModel : ViewModel() { val dbind = FROptionsBuilder.build { server { - url = "https://openam-dbind-upgrade.forgeblocks.com/am" - realm = "alpha" + url = "https://openam-updbind.forgeblocks.com/am" + realm = "bravo" cookieName = "ccdd0582e7262db" timeout = 50 } @@ -57,7 +57,7 @@ class EnvViewModel : ViewModel() { val sdk = FROptionsBuilder.build { server { - url = "https://openam-forgerrock-sdksteanant.forgeblocks.com/am" + url = "https://openam-dbind.forgeblocks.com/am" realm = "alpha" cookieName = "43d72fc37bdde8c" timeout = 50 diff --git a/samples/app/src/main/java/com/example/app/journey/Journey.kt b/samples/app/src/main/java/com/example/app/journey/Journey.kt index f3d62dd7..7d2e3e40 100644 --- a/samples/app/src/main/java/com/example/app/journey/Journey.kt +++ b/samples/app/src/main/java/com/example/app/journey/Journey.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.example.app.Error +import com.example.app.callback.AppIntegrityCallback import com.example.app.callback.ChoiceCallback import com.example.app.callback.ConfirmationCallback import com.example.app.callback.DeviceBindingCallback @@ -35,6 +36,7 @@ import com.example.app.callback.SelectIdPCallback import com.example.app.callback.TextOutputCallback import com.example.app.callback.WebAuthnAuthenticationCallback import com.example.app.callback.WebAuthnRegistrationCallback +import org.forgerock.android.auth.callback.AppIntegrityCallback import org.forgerock.android.auth.callback.ChoiceCallback import org.forgerock.android.auth.callback.ConfirmationCallback import org.forgerock.android.auth.callback.DeviceBindingCallback @@ -44,6 +46,8 @@ import org.forgerock.android.auth.callback.IdPCallback import org.forgerock.android.auth.callback.NameCallback import org.forgerock.android.auth.callback.PasswordCallback import org.forgerock.android.auth.callback.PollingWaitCallback +import org.forgerock.android.auth.callback.ReCaptchaCallback +import com.example.app.callback.ReCaptchaCallback import org.forgerock.android.auth.callback.SelectIdPCallback import org.forgerock.android.auth.callback.TextOutputCallback import org.forgerock.android.auth.callback.WebAuthnAuthenticationCallback @@ -129,12 +133,14 @@ fun Journey(state: JourneyState, is SelectIdPCallback -> { SelectIdPCallback(callback = it, onSelected = onNext) } - /* + is ReCaptchaCallback -> { + ReCaptchaCallback(it, state.node, onCompleted = onNext) + showNext = false + } is AppIntegrityCallback -> { AppIntegrityCallback(callback = it, onCompleted = onNext) showNext = false } - */ is DeviceProfileCallback -> { DeviceProfileCallback(callback = it, onCompleted = onNext) showNext = false diff --git a/samples/app/src/main/java/com/example/app/journey/JourneyViewModel.kt b/samples/app/src/main/java/com/example/app/journey/JourneyViewModel.kt index 70625fed..ec7e082a 100644 --- a/samples/app/src/main/java/com/example/app/journey/JourneyViewModel.kt +++ b/samples/app/src/main/java/com/example/app/journey/JourneyViewModel.kt @@ -8,7 +8,6 @@ package com.example.app.journey import android.content.Context -import android.content.SharedPreferences import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -24,21 +23,26 @@ import java.lang.Exception /** * Should avoid passing context to ViewModel */ -class JourneyViewModel(context: Context, journeyName: T) : ViewModel() { +class JourneyViewModel(context: Context, private var journeyName: T) : ViewModel() { - val sharedPreferences = context.getSharedPreferences("JourneyPreferences", Context.MODE_PRIVATE) + private val sharedPreferences = + context.getSharedPreferences("JourneyPreferences", Context.MODE_PRIVATE) + + var processing: Boolean = false var state = MutableStateFlow(JourneyState()) private set private val nodeListener = object : NodeListener { override fun onSuccess(result: FRSession) { + processing = false state.update { it.copy(null, null, result) } } override fun onException(e: Exception) { + processing = false state.update { //Not keep the node, so that we can retry with previous state it.copy(node = null, exception = e) @@ -53,7 +57,7 @@ class JourneyViewModel(context: Context, journeyName: T) : ViewModel() { } init { - start(context, journeyName) + start(context) } fun saveJourney(journeyName: String) { @@ -71,12 +75,22 @@ class JourneyViewModel(context: Context, journeyName: T) : ViewModel() { } } - private fun start(context: Context, journeyName: T) { - viewModelScope.launch { - if (journeyName is String) { - FRSession.authenticate(context, journeyName, nodeListener) - } else if (journeyName is PolicyAdvice) { - FRSession.getCurrentSession().authenticate(context, journeyName, nodeListener) + fun clear() { + state.update { + it.copy(null, null, null) + } + } + + fun start(context: Context) { + if (!processing) { + processing = true + viewModelScope.launch { + if (journeyName is String) { + FRSession.authenticate(context, journeyName as String, nodeListener) + } else if (journeyName is PolicyAdvice) { + FRSession.getCurrentSession() + .authenticate(context, journeyName as PolicyAdvice, nodeListener) + } } } } @@ -91,6 +105,7 @@ class JourneyViewModel(context: Context, journeyName: T) : ViewModel() { return JourneyViewModel(context.applicationContext, journeyName) as T } } + fun factory( context: Context, journeyName: PolicyAdvice, diff --git a/samples/app/src/main/java/com/example/app/setting/SettingRoute.kt b/samples/app/src/main/java/com/example/app/setting/SettingRoute.kt new file mode 100644 index 00000000..207273e3 --- /dev/null +++ b/samples/app/src/main/java/com/example/app/setting/SettingRoute.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.example.app.setting + +import android.net.Uri +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.app.journey.Journey +import com.example.app.journey.JourneyViewModel +import org.forgerock.android.auth.Action +import org.forgerock.android.auth.FRRequestInterceptor +import org.forgerock.android.auth.PolicyAdvice +import org.forgerock.android.auth.Request +import org.forgerock.android.auth.RequestInterceptorRegistry + +private const val BINDING = "enableBiometric" + +@Composable +fun SettingRoute(viewModel: SettingViewModel) { + + val checked by viewModel.settingState.collectAsState() + + when (checked.transitionState) { + SettingTransitionState.Disabled -> { + BiometricSetting(isChecked = false, viewModel = viewModel) + } + + SettingTransitionState.EnableBinding -> { + + RequestInterceptorRegistry.getInstance() + .register(object : FRRequestInterceptor { + override fun intercept(request: Request, tag: Action?): Request { + return if (tag?.payload?.getString("tree").equals(BINDING) ) { + request.newBuilder() + .url(Uri.parse(request.url().toString()) + .buildUpon() + .appendQueryParameter("ForceAuth", "true").toString()) + .build() + } else request + } + }) + val journeyViewModel = viewModel>( + factory = JourneyViewModel.factory(LocalContext.current, BINDING)) + journeyViewModel.clear() + journeyViewModel.start(LocalContext.current) + Journey(journeyViewModel = journeyViewModel, + onSuccess = { + viewModel.updateStateToEnable() + }, + onFailure = { + viewModel.disable() + } + ) + } + + SettingTransitionState.Enabled -> { + BiometricSetting(isChecked = true, viewModel = viewModel) + } + + else -> {} + } +} + +@Composable +fun BiometricSetting(isChecked: Boolean, viewModel: SettingViewModel) { + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth()) { + Text(text = "Biometric Enable/Disable") + Spacer(modifier = Modifier.weight(1f, true)) + Switch( + checked = isChecked, + onCheckedChange = { + if (it) { + viewModel.enable(); + } else { + viewModel.disable(); + } + } + ) + } +} \ No newline at end of file diff --git a/samples/app/src/main/java/com/example/app/setting/SettingState.kt b/samples/app/src/main/java/com/example/app/setting/SettingState.kt new file mode 100644 index 00000000..d4febdce --- /dev/null +++ b/samples/app/src/main/java/com/example/app/setting/SettingState.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.example.app.setting + +data class SettingState( + var transitionState: SettingTransitionState = SettingTransitionState.Disabled) diff --git a/samples/app/src/main/java/com/example/app/setting/SettingTransitionState.kt b/samples/app/src/main/java/com/example/app/setting/SettingTransitionState.kt new file mode 100644 index 00000000..4b7eb8c9 --- /dev/null +++ b/samples/app/src/main/java/com/example/app/setting/SettingTransitionState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.example.app.setting + +sealed class SettingTransitionState { + object Enabled : SettingTransitionState() + object Disabled : SettingTransitionState() + object EnableBinding: SettingTransitionState() + +} \ No newline at end of file diff --git a/samples/app/src/main/java/com/example/app/setting/SettingViewModel.kt b/samples/app/src/main/java/com/example/app/setting/SettingViewModel.kt new file mode 100644 index 00000000..c4c95124 --- /dev/null +++ b/samples/app/src/main/java/com/example/app/setting/SettingViewModel.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.example.app.setting + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.forgerock.android.auth.FRUserKeys +import org.forgerock.android.auth.devicebind.UserKey + +class SettingViewModel(context: Context) : ViewModel() { + + private val frUserKeys = FRUserKeys(context) + val settingState = MutableStateFlow(SettingState()) + + init { + fetch(null) + } + + fun delete(userKey: UserKey) { + val handler = CoroutineExceptionHandler { _, t -> + fetch(t) + } + viewModelScope.launch(handler) { + frUserKeys.delete(userKey, true) + fetch(null) + } + } + + fun enable() { + settingState.update { + it.copy(transitionState = SettingTransitionState.EnableBinding) + } + } + + fun updateStateToEnable() { + settingState.update { + it.copy(transitionState = SettingTransitionState.Enabled) + } + } + + fun disable() { + frUserKeys.loadAll().forEach { + delete(it) + } + settingState.update { + it.copy(transitionState = SettingTransitionState.Disabled) + } + } + + private fun fetch(t: Throwable?) { + + val state: SettingTransitionState = if (frUserKeys.loadAll().isNotEmpty()) { + SettingTransitionState.Enabled + } else { + SettingTransitionState.Disabled + } + + settingState.update { + it.copy(transitionState = state) + } + } + + companion object { + fun factory( + context: Context + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return SettingViewModel(context.applicationContext) as T + } + } + } +} \ No newline at end of file diff --git a/samples/app/src/main/java/com/example/app/theme/Color.kt b/samples/app/src/main/java/com/example/app/theme/Color.kt index 81f01595..9bc1acf2 100644 --- a/samples/app/src/main/java/com/example/app/theme/Color.kt +++ b/samples/app/src/main/java/com/example/app/theme/Color.kt @@ -5,22 +5,22 @@ * of the MIT license. See the LICENSE file for details. */ -package com.example.app +package com.example.app.theme import androidx.compose.ui.graphics.Color -val md_theme_light_primary = Color(0xFFF96700) +val md_theme_light_primary = Color(0xFFB3282D) val md_theme_light_onPrimary = Color(0xFFFFFFFF) -val md_theme_light_primaryContainer = Color(0xFFFFDAD9) -val md_theme_light_onPrimaryContainer = Color(0xFF40000A) -val md_theme_light_secondary = Color(0xFF0061C8) +val md_theme_light_primaryContainer = Color(0xFFFFDAD7) +val md_theme_light_onPrimaryContainer = Color(0xFF410005) +val md_theme_light_secondary = Color(0xFF775654) val md_theme_light_onSecondary = Color(0xFFFFFFFF) -val md_theme_light_secondaryContainer = Color(0xFFFFDAD9) -val md_theme_light_onSecondaryContainer = Color(0xFF2C1516) -val md_theme_light_tertiary = Color(0xFF755A2F) +val md_theme_light_secondaryContainer = Color(0xFFFFDAD7) +val md_theme_light_onSecondaryContainer = Color(0xFF2C1514) +val md_theme_light_tertiary = Color(0xFF735B2E) val md_theme_light_onTertiary = Color(0xFFFFFFFF) -val md_theme_light_tertiaryContainer = Color(0xFFFFDDAF) -val md_theme_light_onTertiaryContainer = Color(0xFF281800) +val md_theme_light_tertiaryContainer = Color(0xFFFFDEA7) +val md_theme_light_onTertiaryContainer = Color(0xFF271900) val md_theme_light_error = Color(0xFFBA1A1A) val md_theme_light_errorContainer = Color(0xFFFFDAD6) val md_theme_light_onError = Color(0xFFFFFFFF) @@ -29,38 +29,47 @@ val md_theme_light_background = Color(0xFFFFFBFF) val md_theme_light_onBackground = Color(0xFF201A1A) val md_theme_light_surface = Color(0xFFFFFBFF) val md_theme_light_onSurface = Color(0xFF201A1A) -val md_theme_light_surfaceVariant = Color(0xFFF4DDDD) -val md_theme_light_onSurfaceVariant = Color(0xFF524343) -val md_theme_light_outline = Color(0xFF857373) -val md_theme_light_inverseOnSurface = Color(0xFFFBEEED) -val md_theme_light_inverseSurface = Color(0xFF362F2F) -val md_theme_light_inversePrimary = Color(0xFFFFB3B4) -val md_theme_light_surfaceTint = Color(0xFFBF0031) +val md_theme_light_surfaceVariant = Color(0xFFF4DDDB) +val md_theme_light_onSurfaceVariant = Color(0xFF534342) +val md_theme_light_outline = Color(0xFF857371) +val md_theme_light_inverseOnSurface = Color(0xFFFBEEEC) +val md_theme_light_inverseSurface = Color(0xFF362F2E) +val md_theme_light_inversePrimary = Color(0xFFFFB3AE) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFFB3282D) +val md_theme_light_outlineVariant = Color(0xFFD8C2C0) +val md_theme_light_scrim = Color(0xFF000000) -val md_theme_dark_primary = Color(0xFF032B75) -val md_theme_dark_onPrimary = Color(0xFF680016) -val md_theme_dark_primaryContainer = Color(0xFF920023) -val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD9) -val md_theme_dark_secondary = Color(0xFFE6BDBC) -val md_theme_dark_onSecondary = Color(0xFF44292A) -val md_theme_dark_secondaryContainer = Color(0xFF5D3F3F) -val md_theme_dark_onSecondaryContainer = Color(0xFFFFDAD9) -val md_theme_dark_tertiary = Color(0xFFE5C18D) -val md_theme_dark_onTertiary = Color(0xFF422C05) -val md_theme_dark_tertiaryContainer = Color(0xFF5B421A) -val md_theme_dark_onTertiaryContainer = Color(0xFFFFDDAF) +val md_theme_dark_primary = Color(0xFFFFB3AE) +val md_theme_dark_onPrimary = Color(0xFF68000C) +val md_theme_dark_primaryContainer = Color(0xFF900918) +val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD7) +val md_theme_dark_secondary = Color(0xFFE7BDBA) +val md_theme_dark_onSecondary = Color(0xFF442928) +val md_theme_dark_secondaryContainer = Color(0xFF5D3F3D) +val md_theme_dark_onSecondaryContainer = Color(0xFFFFDAD7) +val md_theme_dark_tertiary = Color(0xFFE2C28C) +val md_theme_dark_onTertiary = Color(0xFF402D05) +val md_theme_dark_tertiaryContainer = Color(0xFF594319) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFDEA7) val md_theme_dark_error = Color(0xFFFFB4AB) val md_theme_dark_errorContainer = Color(0xFF93000A) val md_theme_dark_onError = Color(0xFF690005) val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) val md_theme_dark_background = Color(0xFF201A1A) -val md_theme_dark_onBackground = Color(0xFFECE0DF) +val md_theme_dark_onBackground = Color(0xFFEDE0DE) val md_theme_dark_surface = Color(0xFF201A1A) -val md_theme_dark_onSurface = Color(0xFFECE0DF) -val md_theme_dark_surfaceVariant = Color(0xFF524343) -val md_theme_dark_onSurfaceVariant = Color(0xFFD7C1C1) -val md_theme_dark_outline = Color(0xFFA08C8C) +val md_theme_dark_onSurface = Color(0xFFEDE0DE) +val md_theme_dark_surfaceVariant = Color(0xFF534342) +val md_theme_dark_onSurfaceVariant = Color(0xFFD8C2C0) +val md_theme_dark_outline = Color(0xFFA08C8B) val md_theme_dark_inverseOnSurface = Color(0xFF201A1A) -val md_theme_dark_inverseSurface = Color(0xFFECE0DF) -val md_theme_dark_inversePrimary = Color(0xFFBF0031) -val md_theme_dark_surfaceTint = Color(0xFFFFB3B4) \ No newline at end of file +val md_theme_dark_inverseSurface = Color(0xFFEDE0DE) +val md_theme_dark_inversePrimary = Color(0xFFB3282D) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFFFFB3AE) +val md_theme_dark_outlineVariant = Color(0xFF534342) +val md_theme_dark_scrim = Color(0xFF000000) + + +val seed = Color(0xFFB3282D) \ No newline at end of file diff --git a/samples/app/src/main/java/com/example/app/theme/Theme.kt b/samples/app/src/main/java/com/example/app/theme/Theme.kt index 3b06795b..e98541da 100644 --- a/samples/app/src/main/java/com/example/app/theme/Theme.kt +++ b/samples/app/src/main/java/com/example/app/theme/Theme.kt @@ -7,72 +7,12 @@ package com.example.app.theme -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.ui.platform.LocalContext -import com.example.app.md_theme_dark_background -import com.example.app.md_theme_dark_error -import com.example.app.md_theme_dark_errorContainer -import com.example.app.md_theme_dark_inverseOnSurface -import com.example.app.md_theme_dark_inversePrimary -import com.example.app.md_theme_dark_inverseSurface -import com.example.app.md_theme_dark_onBackground -import com.example.app.md_theme_dark_onError -import com.example.app.md_theme_dark_onErrorContainer -import com.example.app.md_theme_dark_onPrimary -import com.example.app.md_theme_dark_onPrimaryContainer -import com.example.app.md_theme_dark_onSecondary -import com.example.app.md_theme_dark_onSecondaryContainer -import com.example.app.md_theme_dark_onSurface -import com.example.app.md_theme_dark_onSurfaceVariant -import com.example.app.md_theme_dark_onTertiary -import com.example.app.md_theme_dark_onTertiaryContainer -import com.example.app.md_theme_dark_outline -import com.example.app.md_theme_dark_primary -import com.example.app.md_theme_dark_primaryContainer -import com.example.app.md_theme_dark_secondary -import com.example.app.md_theme_dark_secondaryContainer -import com.example.app.md_theme_dark_surface -import com.example.app.md_theme_dark_surfaceTint -import com.example.app.md_theme_dark_surfaceVariant -import com.example.app.md_theme_dark_tertiary -import com.example.app.md_theme_dark_tertiaryContainer -import com.example.app.md_theme_light_background -import com.example.app.md_theme_light_error -import com.example.app.md_theme_light_errorContainer -import com.example.app.md_theme_light_inverseOnSurface -import com.example.app.md_theme_light_inversePrimary -import com.example.app.md_theme_light_inverseSurface -import com.example.app.md_theme_light_onBackground -import com.example.app.md_theme_light_onError -import com.example.app.md_theme_light_onErrorContainer -import com.example.app.md_theme_light_onPrimary -import com.example.app.md_theme_light_onPrimaryContainer -import com.example.app.md_theme_light_onSecondary -import com.example.app.md_theme_light_onSecondaryContainer -import com.example.app.md_theme_light_onSurface -import com.example.app.md_theme_light_onSurfaceVariant -import com.example.app.md_theme_light_onTertiary -import com.example.app.md_theme_light_onTertiaryContainer -import com.example.app.md_theme_light_outline -import com.example.app.md_theme_light_primary -import com.example.app.md_theme_light_primaryContainer -import com.example.app.md_theme_light_secondary -import com.example.app.md_theme_light_secondaryContainer -import com.example.app.md_theme_light_surface -import com.example.app.md_theme_light_surfaceTint -import com.example.app.md_theme_light_surfaceVariant -import com.example.app.md_theme_light_tertiary -import com.example.app.md_theme_light_tertiaryContainer - private val LightColors = lightColorScheme( primary = md_theme_light_primary, onPrimary = md_theme_light_onPrimary, @@ -101,8 +41,11 @@ private val LightColors = lightColorScheme( inverseSurface = md_theme_light_inverseSurface, inversePrimary = md_theme_light_inversePrimary, surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, ) + private val DarkColors = darkColorScheme( primary = md_theme_dark_primary, onPrimary = md_theme_dark_onPrimary, @@ -131,24 +74,23 @@ private val DarkColors = darkColorScheme( inverseSurface = md_theme_dark_inverseSurface, inversePrimary = md_theme_dark_inversePrimary, surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, ) - @Composable fun AppTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit + useDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable() () -> Unit ) { - val colorScheme = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } else { - if (darkTheme) DarkColors else LightColors - } + val colors = if (!useDarkTheme) { + LightColors + } else { + DarkColors + } MaterialTheme( - colorScheme = colorScheme, + colorScheme = colors, shapes = AppShapes, typography = AppTypography, content = content diff --git a/samples/app/src/main/res/animator/logo_animator.xml b/samples/app/src/main/res/animator/logo_animator.xml new file mode 100644 index 00000000..054ea78f --- /dev/null +++ b/samples/app/src/main/res/animator/logo_animator.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/samples/app/src/main/res/drawable/animated_logo.xml b/samples/app/src/main/res/drawable/animated_logo.xml new file mode 100644 index 00000000..64b8abe0 --- /dev/null +++ b/samples/app/src/main/res/drawable/animated_logo.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/samples/app/src/main/res/drawable/ping_logo.xml b/samples/app/src/main/res/drawable/ping_logo.xml new file mode 100644 index 00000000..f5b7ee23 --- /dev/null +++ b/samples/app/src/main/res/drawable/ping_logo.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/samples/app/src/main/res/values/colors.xml b/samples/app/src/main/res/values/colors.xml index 9ca7b85b..0e5a5ef9 100644 --- a/samples/app/src/main/res/values/colors.xml +++ b/samples/app/src/main/res/values/colors.xml @@ -7,11 +7,11 @@ --> - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 + #DB4332 + #B3282D + #DB4332 + #69747D + #505D68 + #051727 #FFFFFFFF \ No newline at end of file diff --git a/samples/app/src/main/res/values/splash.xml b/samples/app/src/main/res/values/splash.xml deleted file mode 100644 index c3a3763a..00000000 --- a/samples/app/src/main/res/values/splash.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/samples/app/src/main/res/values/themes.xml b/samples/app/src/main/res/values/themes.xml index 0fe60a60..0f7ac09b 100644 --- a/samples/app/src/main/res/values/themes.xml +++ b/samples/app/src/main/res/values/themes.xml @@ -15,15 +15,7 @@ @@ -36,4 +28,13 @@ + + + + \ No newline at end of file diff --git a/samples/auth/build.gradle b/samples/auth/build.gradle index 09545783..027b5434 100644 --- a/samples/auth/build.gradle +++ b/samples/auth/build.gradle @@ -61,16 +61,6 @@ repositories { } } -configurations.all { - resolutionStrategy { - force 'com.google.android.gms:play-services-basement:18.1.0' - //Due to Vulnerability [CVE-2023-3635] CWE-681: Incorrect Conversion between Numeric Types - //on version < 3.4.0, this library is depended by okhttp, when okhttp upgrade, this needs - //to be reviewed - force 'com.squareup.okio:okio:3.4.0' - } -} - dependencies { implementation project(':forgerock-auth') @@ -92,13 +82,16 @@ dependencies { implementation 'androidx.annotation:annotation:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'net.openid:appauth:0.7.1' + implementation 'net.openid:appauth:0.11.1' implementation 'com.google.android.gms:play-services-fido:20.0.1' //SocialLogin implementation 'com.google.android.gms:play-services-auth:20.5.0' implementation 'com.facebook.android:facebook-login:16.0.0' + //For App integrity + implementation 'com.google.android.play:integrity:1.3.0' + //Device Binding + JWT implementation 'com.nimbusds:nimbus-jose-jwt:9.25' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' diff --git a/samples/auth/src/main/java/org/forgerock/auth/CustomCookieInterceptor.java b/samples/auth/src/main/java/org/forgerock/auth/CustomCookieInterceptor.java new file mode 100644 index 00000000..0a490807 --- /dev/null +++ b/samples/auth/src/main/java/org/forgerock/auth/CustomCookieInterceptor.java @@ -0,0 +1,37 @@ +package org.forgerock.auth; + +import androidx.annotation.NonNull; + +import org.forgerock.android.auth.Action; +import org.forgerock.android.auth.CookieInterceptor; +import org.forgerock.android.auth.FRRequestInterceptor; +import org.forgerock.android.auth.Request; + +import java.util.ArrayList; +import java.util.List; + +import okhttp3.Cookie; + +public class CustomCookieInterceptor implements FRRequestInterceptor, CookieInterceptor { + @NonNull + @Override + public Request intercept(@NonNull Request request) { + return request; + } + + @NonNull + @Override + public Request intercept(@NonNull Request request, Action tag) { + return request; + } + + @NonNull + @Override + public List intercept(@NonNull List cookies) { + List newCookies = new ArrayList<>(); + newCookies.addAll(cookies); + newCookies.add(new Cookie.Builder().domain("localhost").name("test").value("testValue").httpOnly().secure().build()); + + return newCookies; + } +} diff --git a/samples/auth/src/main/java/org/forgerock/auth/MainActivity.java b/samples/auth/src/main/java/org/forgerock/auth/MainActivity.java index a4b09acd..f4a76389 100644 --- a/samples/auth/src/main/java/org/forgerock/auth/MainActivity.java +++ b/samples/auth/src/main/java/org/forgerock/auth/MainActivity.java @@ -93,6 +93,7 @@ protected void onCreate(Bundle savedInstanceState) { /* RequestInterceptorRegistry.getInstance().register( new ForceAuthRequestInterceptor(), + new CustomCookieInterceptor(), new NoSessionRequestInterceptor() ); */ diff --git a/samples/kotlin/build.gradle b/samples/kotlin/build.gradle index 9a1ca09c..d9537f35 100644 --- a/samples/kotlin/build.gradle +++ b/samples/kotlin/build.gradle @@ -68,12 +68,6 @@ android { } } -configurations.all { - resolutionStrategy { - force 'com.google.android.gms:play-services-basement:18.1.0' - } -} - dependencies { implementation project(':forgerock-auth') implementation 'net.openid:appauth:0.11.1' diff --git a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/FRSessionActivity.kt b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/FRSessionActivity.kt index 57a5785c..6734d11f 100644 --- a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/FRSessionActivity.kt +++ b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/FRSessionActivity.kt @@ -42,7 +42,7 @@ class FRSessionActivity: AppCompatActivity(), NodeListener, ActivityL } } - override fun onSuccess(result: FRSession?) { + override fun onSuccess(result: FRSession) { getAccessToken() } @@ -51,7 +51,7 @@ class FRSessionActivity: AppCompatActivity(), NodeListener, ActivityL print("------> $e") } - override fun onCallbackReceived(node: Node?) { + override fun onCallbackReceived(node: Node) { nodeDialog?.dismiss() nodeDialog = NodeDialogFragment.newInstance(node) nodeDialog?.show(supportFragmentManager, NodeDialogFragment::class.java.name) diff --git a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/MainActivity.kt b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/MainActivity.kt index 8ba7302c..c958af6f 100644 --- a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/MainActivity.kt +++ b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/MainActivity.kt @@ -150,7 +150,7 @@ class MainActivity : AppCompatActivity(), NodeListener, ActivityListener } } - override fun onSuccess(result: FRUser?) { + override fun onSuccess(result: FRUser) { getUserInfo(result) } @@ -174,7 +174,7 @@ class MainActivity : AppCompatActivity(), NodeListener, ActivityListener } @RequiresApi(Build.VERSION_CODES.M) - override fun onCallbackReceived(node: Node?) { + override fun onCallbackReceived(node: Node) { val activity = this var nodeDialog = supportFragmentManager.findFragmentByTag(NodeDialogFragment.TAG) as? NodeDialogFragment