diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 5b998e1b4..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: 2.1 - -orbs: - codecov: codecov/codecov@3 - -jobs: - build: - environment: - GRADLE_OPTS: -Dkotlin.compiler.execution.strategy=in-process - docker: - - image: cimg/android:2022.03.1 - - steps: - - checkout - - - run: - name: Run checks - command: ./gradlew clean test jacocoTestReport lint --continue --console=plain --max-workers=1 --no-daemon - - - store_artifacts: - path: auth0/build/reports - destination: reports - - - store_test_results: - path: auth0/build/test-results - - - codecov/upload - -workflows: - build-and-test: - jobs: - - build diff --git a/.github/actions/maven-publish/action.yml b/.github/actions/maven-publish/action.yml index 2aac38757..20b8e767c 100644 --- a/.github/actions/maven-publish/action.yml +++ b/.github/actions/maven-publish/action.yml @@ -21,12 +21,14 @@ runs: uses: actions/checkout@v4 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # pin@v4 with: distribution: 'temurin' - java-version: '11' + java-version: ${{ inputs.java-version }} + cache: 'gradle' - - uses: gradle/wrapper-validation-action@56b90f209b02bf6d1deae490e9ef18b21a389cd4 # pin@1.1.0 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # pin@v5 - name: Publish Android/Java Packages to Maven shell: bash @@ -36,4 +38,3 @@ runs: MAVEN_PASSWORD: ${{ inputs.ossr-token }} SIGNING_KEY: ${{ inputs.signing-key}} SIGNING_PASSWORD: ${{ inputs.signing-password}} - diff --git a/.github/actions/rl-scanner/action.yml b/.github/actions/rl-scanner/action.yml deleted file mode 100644 index 9fd39bc9f..000000000 --- a/.github/actions/rl-scanner/action.yml +++ /dev/null @@ -1,73 +0,0 @@ - -name: 'RL-Security-Scanner' -description: 'Runs the Reversing Labs scanner on a specified artifact.' -inputs: - artifact-path: - description: 'Path to the artifact to be scanned.' - required: true - version: - description: 'Version of the artifact.' - required: true - - -runs: - using: 'composite' - steps: - - name: Set up python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - - name: Install Python dependencies - shell: bash - run: | - pip install boto3 requests - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - role-to-assume: ${{ env.PRODSEC_TOOLS_ARN }} - aws-region: 'us-east-1' - mask-aws-account-id: true - - - name: Install RL Wrapper - shell: bash - run: | - pip install rl-wrapper --index-url "https://${{ env.PRODSEC_TOOLS_USER }}:${{ env.PRODSEC_TOOLS_TOKEN }}@a0us.jfrog.io/artifactory/api/pypi/python/simple" - - - name: Run RL Scanner - shell: bash - env: - RLSECURE_LICENSE: ${{ env.RLSECURE_LICENSE }} - RLSECURE_SITE_KEY: ${{ env.RLSECURE_SITE_KEY }} - SIGNAL_HANDLER_TOKEN: ${{ env.SIGNAL_HANDLER_TOKEN }} - PYTHONUNBUFFERED: 1 - run: | - if [ ! -f "${{ inputs.artifact-path }}" ]; then - echo "Artifact not found: ${{ inputs.artifact-path }}" - exit 1 - fi - - rl-wrapper \ - --artifact "${{ inputs.artifact-path }}" \ - --version "${{ inputs.version }}" \ - --name "${{ github.event.repository.name }}" \ - --repository "${{ github.repository }}" \ - --commit "${{ github.sha }}" \ - --build-env "github_actions" \ - --suppress_output - - # Check the outcome of the scanner - if [ $? -ne 0 ]; then - echo "RL Scanner failed." - echo "scan-status=failed" >> $GITHUB_ENV - exit 1 - else - echo "RL Scanner passed." - echo "scan-status=success" >> $GITHUB_ENV - fi - -outputs: - scan-status: - description: 'The outcome of the scan process.' - value: ${{ env.scan-status }} diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d18c43454..6107b35ab 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -5,34 +5,25 @@ inputs: java: description: The Java version to use required: false - default: 8.0.382-tem - gradle: - description: The Gradle version to use - required: false - default: 6.7.1 - kotlin: - description: The Kotlin version to use - required: false - default: 1.6.21 + default: '17' runs: using: composite steps: - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # pin@v4 with: distribution: 'temurin' - java-version: '11' + java-version: ${{ inputs.java }} + cache: 'gradle' - - run: | - curl -s "https://get.sdkman.io" | bash - source "/home/runner/.sdkman/bin/sdkman-init.sh" - sdk install gradle ${{ inputs.gradle }} && sdk default gradle ${{ inputs.gradle }} - sdk install kotlin ${{ inputs.kotlin }} && sdk default kotlin ${{ inputs.kotlin }} - shell: bash + - name: Set up Gradle + uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # pin@v5 + with: + gradle-version: wrapper + cache-cleanup: on-success - - run: ./gradlew androidDependencies + - name: Download dependencies + run: ./gradlew androidDependencies shell: bash - - - uses: gradle/wrapper-validation-action@56b90f209b02bf6d1deae490e9ef18b21a389cd4 # pin@1.1.0 \ No newline at end of file diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index d2183bca9..000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Claude Code PR Review - -on: - issue_comment: - types: [ created ] - pull_request_review_comment: - types: [ created ] - pull_request_review: - types: [ submitted ] - -jobs: - claude-review: - permissions: - contents: write - issues: write - pull-requests: write - id-token: write - uses: auth0/auth0-ai-pr-analyzer-gh-action/.github/workflows/claude-code-review.yml@main \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d9d498320..8039f9fe9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,13 +36,13 @@ jobs: run: exit 0 # Skip unnecessary test runs for dependabot and merge queues. Artifically flag as successful, as this is a required check for branch protection. - name: Set up Java - uses: actions/setup-java@v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' - java-version: '11' + java-version: '17' - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml index 32e097978..0e2170964 100644 --- a/.github/workflows/java-release.yml +++ b/.github/workflows/java-release.yml @@ -24,13 +24,13 @@ on: jobs: release: - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/') && github.event.pull_request.base.ref == 'v4_development') runs-on: ubuntu-latest environment: release steps: # Checkout the code - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d74a96c02..398c74b2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: pull_request: types: - closed + branches: + - v4_development workflow_dispatch: permissions: @@ -16,23 +18,42 @@ permissions: jobs: rl-scanner: - uses: ./.github/workflows/rl-scanner.yml - with: - java-version: 8.0.402-zulu - artifact-name: 'auth0-release.aar' - secrets: - RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} - RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} - SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} - PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} - PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} - PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Build with gradle + run: ./gradlew :auth0:assembleRelease + + - name: Get version + id: get_version + run: | + version=$(cat .version) + echo "version=$version" >> $GITHUB_OUTPUT + + - name: Run RL Scanner + uses: auth0/devsecops-tooling/.github/actions/rl-scan@main + with: + artifact-name: "auth0-android" + artifact-path: "${{ github.workspace }}/auth0/build/outputs/aar/auth0-release.aar" + version: ${{ steps.get_version.outputs.version }} + RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} + RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} + SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} + SIGNAL_HANDLER_DOMAIN: ${{ secrets.SIGNAL_HANDLER_DOMAIN }} + PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} + PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} + PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} + PRODSEC_PYTHON_TOOLS_REPO: ${{ secrets.PRODSEC_PYTHON_TOOLS_REPO }} release: uses: ./.github/workflows/java-release.yml needs: rl-scanner with: - java-version: 8.0.402-zulu + java-version: '17' secrets: ossr-username: ${{ secrets.OSSR_USERNAME }} ossr-token: ${{ secrets.OSSR_TOKEN }} diff --git a/.github/workflows/rl-scanner.yml b/.github/workflows/rl-scanner.yml index bc3dca514..e69de29bb 100644 --- a/.github/workflows/rl-scanner.yml +++ b/.github/workflows/rl-scanner.yml @@ -1,78 +0,0 @@ -name: RL-Security-Scanner -run-name: rl-security-scanner - - -on: - workflow_call: - inputs: - java-version: - required: true - type: string - artifact-name: - required: true - type: string - secrets: - RLSECURE_LICENSE: - required: true - RLSECURE_SITE_KEY: - required: true - SIGNAL_HANDLER_TOKEN: - required: true - PRODSEC_TOOLS_USER: - required: true - PRODSEC_TOOLS_TOKEN: - required: true - PRODSEC_TOOLS_ARN: - required: true - - -jobs: - rl-scanner: - name: Run Reversing Labs Scanner - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) - runs-on: ubuntu-latest - outputs: - scan-status: ${{ steps.rl-scan-conclusion.outcome }} - - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Build with gradle - shell: bash - run: ./gradlew :auth0:assembleRelease - - - name: Get Artifact Version - id: get_version - run: | - version=$(cat .version) - echo "version=$version" >> $GITHUB_OUTPUT - - - name: List build contents - run: ls -la auth0/build/outputs/aar - - - name: Output build artifact - id: output_build_artifact - run: | - echo "scanfile=$(pwd)/auth0/build/outputs/aar/auth0-release-${{ steps.get_version.outputs.version }}.aar" >> $GITHUB_OUTPUT - - - - name: Run Reversing Labs Scanner - id: rl-scan-conclusion - uses: ./.github/actions/rl-scanner - with: - artifact-path: "$(pwd)/auth0/build/outputs/aar/${{ inputs.artifact-name }}" - version: "${{ steps.get_version.outputs.version }}" - env: - RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} - RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} - SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} - PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} - PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} - PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} - - - name: Output scan result - run: echo "scan-status=${{ steps.rl-scan-conclusion.outcome }}" >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f6a5ea5e..6b82f5cdc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,7 @@ on: pull_request: branches: - main + - v4_development push: branches: - main @@ -23,10 +24,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: ./.github/actions/setup - - run: ./gradlew clean test jacocoTestReport lint --continue --console=plain --max-workers=1 --no-daemon + - run: ./gradlew testReleaseUnitTest jacocoTestReleaseUnitTestReport lintRelease --continue --console=plain - - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@5.5.2 + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@7.0.0 diff --git a/.version b/.version index 77fdc6bb0..2f2ab5313 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.13.0 \ No newline at end of file +4.0.0-beta.1 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index ab476686e..a61b28cc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,253 +1,388 @@ # AI Agent Guidelines for Auth0.Android SDK -This document provides context and guidelines for AI coding assistants working with the Auth0.Android SDK codebase. +This document gives practical, codebase-accurate guidance for AI coding assistants working on Auth0.Android. -## Project Overview +## Project Snapshot -**Auth0.Android** is a native Android SDK for integrating Auth0 authentication and authorization into Android applications. The SDK provides a comprehensive solution for: +Auth0.Android is a native Android SDK for authentication and authorization with Auth0, including: -- Web-based authentication (Universal Login via Custom Tabs) -- Direct API authentication (database connections, passwordless) -- Secure credential storage with biometric protection -- Token management with automatic refresh -- Modern Android development patterns (Coroutines, AndroidX libraries) +- Web authentication via Universal Login and browser-based flows +- Direct authentication APIs (database, passwordless, token operations) +- Credential persistence and automatic renewal +- Secure credential storage with Android Keystore and biometrics +- Kotlin-first APIs with Java interoperability and coroutine support ## Repository Structure ``` Auth0.Android/ -├── auth0/ # Main SDK library module +├── auth0/ # Main SDK library module │ ├── src/main/java/com/auth0/android/ -│ │ ├── provider/ # Browser-based auth providers -│ │ ├── authentication/ # Direct API authentication -│ │ ├── management/ # Management API client -│ │ ├── myaccount/ # My Account API client -│ │ ├── request/ # Network request abstractions -│ │ ├── result/ # Response/error handling -│ │ └── Auth0.kt # Main configuration class -│ └── src/test/ # Unit tests -├── sample/ # Demo application -├── .github/ # CI/CD workflows -├── gradle/ # Build configuration -│ ├── versioning.gradle # Version management -│ └── maven-publish.gradle # Publishing setup -└── .version # Current SDK version +│ │ ├── authentication/ # Authentication API client, grants, MFA, and storage +│ │ ├── callback/ # Callback interfaces +│ │ ├── dpop/ # DPoP proof-of-possession support +│ │ ├── myaccount/ # My Account API client +│ │ ├── provider/ # Browser authentication providers +│ │ ├── request/ # Request abstractions +│ │ ├── result/ # Response and error models +│ │ └── Auth0.kt # Main account/config entry point +│ └── src/test/ # Unit tests (Robolectric/JUnit) +├── sample/ # Demo app module +├── .github/workflows/ # CI and release workflows +├── gradle/versioning.gradle # Version resolution from .version +├── gradle/maven-publish.gradle # Maven publishing config +└── .version # SDK version source of truth ``` -## Key Technical Decisions - -### Architecture Patterns -- **Builder Pattern**: Used extensively for web based authentication flows (e.g., `WebAuthProvider.login()`) -- **Callback + Coroutines**: Dual API support for both traditional callbacks and modern suspend functions -- **Provider Architecture**: Pluggable authentication providers with fallback strategies - -### Authentication Flow -1. **WebAuthProvider** (Recommended): Browser-based auth via Custom Tabs - - Uses App Links (`https://` schemes) or custom URL schemes - - Handles PKCE automatically - - Supports DPoP for enhanced security - -2. **AuthenticationAPIClient**: Direct API calls without browser - - Database connections (login/signup) - - Passwordless (email/SMS) - - Token refresh and revocation - -### Credential Management Strategy -- **CredentialsManager**: Basic storage with automatic refresh -- **SecureCredentialsManager**: Adds biometric/device credential protection with encrypted storage -- Storage abstraction via `Storage` interface (default: SharedPreferences) -- Encryption using Android Keystore - -## Development Guidelines - -### Code Style -- **Language**: Kotlin (primary), with Java interop support -- **Minimum SDK**: API 21 (Android 5.0) -- **Target SDK**: Latest stable Android version -- **Testing**: Robolectric for Android components, MockWebServer for HTTP - -### API Design Principles -When adding or modifying APIs: - -1. **Dual API Support**: Provide both callback and suspend function variants - ```kotlin - // Callback style - fun operation(callback: Callback) - - // Coroutine style - suspend fun operation(): Result - ``` - -2. **Builder Pattern**: Use for WebAuthProvider operations - ```kotlin - WebAuthProvider.login(account) - .withScheme("https") - .withScope("openid profile") - .start(context, callback) - ``` - -3. **Error Handling**: Use typed exceptions - - `AuthenticationException` for auth failures - - `CredentialsManagerException` for storage issues - - All inherit from `Auth0Exception` - -### Testing Requirements -- Unit tests for all new functionality -- Code coverage tracked via JaCoCo (target: >80%) -- Mock external dependencies (network, Android framework) -- Test both success and failure scenarios - -### Common Tasks - -#### Adding a New Authentication Method -1. Create request class in `auth0/src/main/java/com/auth0/android/request/` -2. Implement both callback and suspend variants -3. Add unit tests with mocked responses -4. Update `EXAMPLES.md` with usage example -5. Add integration test in sample app - -#### Modifying Browser Authentication -Key files: -- `WebAuthProvider.kt`: Main entry point -- `AuthenticationActivity.kt`: Handles redirects -- `OAuthManager.kt`: OAuth2 flow logic -- `PKCE.kt`: PKCE implementation - -#### Updating Credential Storage -Key files: -- `CredentialsManager.kt`: Basic implementation -- `SecureCredentialsManager.kt`: Biometric support -- `SharedPreferencesStorage.kt`: Persistence layer - -## Build & Testing Commands +## Current Technical Baseline + +Source of truth: `auth0/build.gradle`, `sample/build.gradle`, and root `build.gradle`. + +- Minimum SDK: API 26 +- Compile SDK: 36 +- Target SDK: 36 +- Java source/target compatibility: 17 +- Kotlin JVM target: 17 +- Kotlin Gradle plugin: 2.0.21 +- Android Gradle Plugin: 8.10.1 +- Kotlin explicit API mode: **strict** (`-Xexplicit-api=strict`) — all public declarations must have explicit visibility modifiers and return types + +## Core Development Principles + +1. Preserve dual async APIs when applicable: + - Callback-based methods for Java/legacy consumers + - Coroutine/suspend alternatives for Kotlin consumers +2. Follow existing builder-style ergonomics for browser auth flows. +3. Keep error handling typed and actionable: + - `AuthenticationException` + - `CredentialsManagerException` + - Other `Auth0Exception` subclasses +4. Maintain backward compatibility for public SDK APIs unless the change is explicitly breaking and documented. +5. Do not weaken security defaults (PKCE, token handling, Keystore usage, DPoP behavior). + +## Testing Expectations + +### Required Coverage for Changes + +- Add or update unit tests for each behavior change. +- Cover success and failure/error paths. +- Mock network and Android framework dependencies as needed. +- Keep tests deterministic (control time, threading, and async execution in tests). + +### Current Test Stack + +From `auth0/build.gradle`: + +- JUnit 4 (`junit:junit:4.13.2`) +- Robolectric (`org.robolectric:robolectric:4.15.1`) +- Mockito Core + Mockito Kotlin +- Hamcrest +- MockWebServer + OkHttp TLS test helpers +- Awaitility +- Kotlin Coroutines Test +- Espresso Intents (for relevant Android interaction test cases) + +### Commands ```bash -# Full build with tests and coverage -./gradlew clean test jacocoTestReport +# Match current CI unit/lint pipeline +./gradlew testReleaseUnitTest jacocoTestReleaseUnitTestReport lintRelease --continue --console=plain -# Run lint checks +# Full lint (all variants) ./gradlew lint # Build sample app ./gradlew sample:assembleDebug - -# CI simulation (matches GitHub Actions) -./gradlew clean test jacocoTestReport lint --continue --console=plain --max-workers=1 --no-daemon ``` -## Configuration Files - -### Version Management -- **`.version`**: Single source of truth for SDK version -- Read by `gradle/versioning.gradle` and injected into BuildConfig - -### Required Manifest Placeholders -```gradle -android { - defaultConfig { - manifestPlaceholders = [ - auth0Domain: "YOUR_DOMAIN", - auth0Scheme: "https" // or custom scheme - ] - } -} -``` +## Common Task Playbooks -### String Resources Pattern -```xml -YOUR_CLIENT_ID -YOUR_DOMAIN -``` +### Adding or Changing Authentication APIs + +1. Implement request/client behavior under `auth0/src/main/java/com/auth0/android/`. +2. Ensure both callback and suspend usage remains coherent when relevant. +3. Add/update unit tests under `auth0/src/test/`. +4. Update public documentation/examples (`EXAMPLES.md`, `README.md`) for public API changes. + +### Browser Authentication Changes -## Dependencies +Primary files typically include: -### Core Libraries -- **AndroidX**: Activity, Browser, Biometric, Lifecycle -- **Kotlin Coroutines**: For async operations -- **OkHttp**: HTTP client -- **Gson**: JSON serialization -- **JWT**: Token parsing and validation +- `provider/WebAuthProvider.kt` +- `provider/AuthenticationActivity.kt` +- `provider/OAuthManager.kt` +- PKCE and browser option helpers in `provider/` -### Testing Libraries -- **JUnit 4**: Test framework -- **Robolectric**: Android unit testing -- **Mockito/PowerMock**: Mocking -- **MockWebServer**: HTTP testing -- **Hamcrest**: Assertions +### Credentials Storage Changes -## Security Considerations +Primary files typically include: -1. **PKCE**: Enabled by default for all OAuth flows -2. **DPoP**: Optional enhanced token security -3. **Keystore**: All credentials encrypted using Android Keystore -4. **Biometric**: LocalAuthentication for secure access -5. **Certificate Pinning**: Configurable via OkHttp interceptors +- `authentication/storage/CredentialsManager.kt` +- `authentication/storage/SecureCredentialsManager.kt` +- storage and crypto helper classes used by those managers -## Documentation +## Quick Integration Examples -- **README.md**: Getting started and installation -- **EXAMPLES.md**: Detailed usage examples -- **API docs**: Generated via Dokka (KDoc comments) -- **CHANGELOG.md**: Release notes and breaking changes -- **MIGRATION.md**: Upgrade guides between major versions +Use these as reference snippets when implementing or reviewing code changes. -## Release Process +### Initialize Core Classes -1. Update `.version` file -2. Update `CHANGELOG.md` -3. Create release branch -4. CI runs full test suite -5. Manual approval for publication -6. Maven Central publication via `gradle/maven-publish.gradle` +```kotlin +val account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") +val authClient = AuthenticationAPIClient(account) -## Common Pitfalls +val storage = SharedPreferencesStorage(context) +val secureCredentialsManager = SecureCredentialsManager(context, account, storage) +``` -- **Redirect URIs**: Must match exactly between Auth0 dashboard and app configuration -- **Custom Tabs**: Require Chrome or Chrome Custom Tabs provider installed -- **Biometric**: Requires device credential fallback configuration -- **Coroutines**: Must use appropriate dispatcher for Android operations -- **Proguard**: Keep rules defined in `consumer-rules.pro` +### Trigger Web Auth Login and Logout -## Getting Help +```kotlin +WebAuthProvider.login(account) + .withScheme("https") + .withScope("openid profile email") + .start(activity, object : Callback { + override fun onSuccess(result: Credentials) { + // Save or use credentials + } + + override fun onFailure(error: AuthenticationException) { + // Handle cancellation, browser unavailable, or auth errors + } + }) + +WebAuthProvider.logout(account) + .withScheme("https") + .start(activity, object : Callback { + override fun onSuccess(result: Void?) { + // Session cleared + } + + override fun onFailure(error: AuthenticationException) { + // Handle logout error + } + }) +``` -- **Issues**: GitHub Issues for bugs and feature requests -- **Discussions**: GitHub Discussions for questions -- **Auth0 Community**: https://community.auth0.com/ -- **Auth0 Support**: For Auth0 account/dashboard issues +Coroutine style: -## AI Agent Best Practices +```kotlin +try { + val credentials = WebAuthProvider.login(account) + .withScheme("https") + .await(activity) + + // Use credentials +} catch (e: AuthenticationException) { + // Handle auth error +} +``` -When assisting with this codebase: +### Make Authentication API Calls -1. **Preserve patterns**: Follow existing Builder and callback/coroutine patterns -2. **Test coverage**: Always include tests for new functionality -3. **Backward compatibility**: Consider impact on existing users -4. **Documentation**: Update relevant docs when changing public APIs -5. **Security**: Never compromise security features (PKCE, encryption, etc.) -6. **Android compatibility**: Test across Android versions (API 21+) -7. **Error handling**: Provide clear, actionable error messages +Database login: -## Example Workflows +```kotlin +authClient + .login("user@example.com", "password", "Username-Password-Authentication") + .validateClaims() + .start(object : Callback { + override fun onSuccess(result: Credentials) { + secureCredentialsManager.saveCredentials(result) + } + + override fun onFailure(error: AuthenticationException) { + // Handle API auth errors + } + }) +``` + +Get user profile: -### Web Authentication ```kotlin -val account = Auth0(clientId, domain) -WebAuthProvider.login(account) - .withScheme("https") - .withScope("openid profile email") - .start(context, object : Callback { - override fun onSuccess(result: Credentials) { /* ... */ } - override fun onFailure(error: AuthenticationException) { /* ... */ } - }) +authClient + .userInfo("ACCESS_TOKEN") + .start(object : Callback { + override fun onSuccess(result: UserProfile) { + // Use profile + } + + override fun onFailure(error: AuthenticationException) { + // Handle API error + } + }) ``` +Coroutine style: -### Direct API Authentication ```kotlin -val authClient = AuthenticationAPIClient(account) -authClient.login(email, password, "Username-Password-Authentication") - .start(callback) +try { + val credentials = authClient + .login("user@example.com", "password", "Username-Password-Authentication") + .validateClaims() + .await() + + secureCredentialsManager.saveCredentials(credentials) + + val profile = authClient.userInfo(credentials.accessToken ?: "").await() + // Use profile +} catch (e: AuthenticationException) { + // Handle API/authentication errors +} ``` ---- +## Class Responsibilities and Where to Modify + +Use this as a quick decision guide when implementing new features. + +### Core Class Responsibilities + +- `Auth0` (`Auth0.kt`) + - SDK configuration entry point (client ID, domain, networking client). + - Update when adding new global SDK configuration knobs. + +- `AuthenticationAPIClient` (`authentication/AuthenticationAPIClient.kt`) + - Direct Authentication API operations (login/signup/passwordless/token/user info/MFA entry points). + - Update for new auth endpoints, grants, or request options. + +- `WebAuthProvider` (`provider/WebAuthProvider.kt`) + - Browser-based login/logout builder APIs and options. + - Update when adding Web Auth options or flow behavior. + +- `OAuthManager` (`provider/OAuthManager.kt`) + - Internal browser flow orchestration, URL construction, state/PKCE interactions. + - Update for protocol-level web auth flow changes. + +- `AuthenticationActivity` (`provider/AuthenticationActivity.kt`) + - Redirect handling and callback resume behavior for browser auth. + - Update for redirect parsing, lifecycle, and result-delivery changes. + +- `CredentialsManager` (`authentication/storage/CredentialsManager.kt`) + - Token caching and refresh orchestration for non-biometric manager. + - Update for refresh and credential lifecycle logic. + +- `SecureCredentialsManager` (`authentication/storage/SecureCredentialsManager.kt`) + - Encrypted credential storage, biometric/local authentication policy integration. + - Update for secure storage behavior, biometric policy, and key handling. + +- `SharedPreferencesStorage` (`authentication/storage/SharedPreferencesStorage.kt`) + - Persistence adapter used by credentials managers. + - Update only when changing storage key/value persistence mechanics. + +- `MfaApiClient` (`authentication/mfa/MfaApiClient.kt`) + - MFA operations: list authenticators, enroll, challenge, and verify. + - Update when adding new MFA factor types or verification flows. + +- `MyAccountAPIClient` (`myaccount/MyAccountAPIClient.kt`) + - My Account API operations (passkey enrollment, authentication method management). + - Update when adding new self-service account management capabilities. + +- `DPoP` and `DPoPKeyStore` (`dpop/`) + - DPoP proof generation, key pair management, and header construction. + - Update for DPoP protocol changes, key lifecycle, or nonce handling. + +- `AuthenticationException` and `CredentialsManagerException` + - Typed error surfaces for API/storage failures. + - Update when introducing new error conditions or mappings. + +### Feature-to-Class Mapping + +- New Authentication API capability: + - Start in `authentication/AuthenticationAPIClient.kt` + - Add/update request models under `request/` + - Add/update result/error models under `result/` + +- New Web Auth builder option: + - Start in `provider/WebAuthProvider.kt` + - If flow internals change, also update `provider/OAuthManager.kt` + +- Browser callback/redirect behavior change: + - Start in `provider/AuthenticationActivity.kt` + - Validate interactions with `provider/WebAuthProvider.kt` + +- Credential save/load/refresh behavior change: + - Start in `authentication/storage/CredentialsManager.kt` + - Mirror secure variant behavior in `authentication/storage/SecureCredentialsManager.kt` when applicable + +- Biometric or local-auth policy change: + - Start in `authentication/storage/SecureCredentialsManager.kt` + - Update related local-auth option/policy classes in `authentication/storage/` as needed + +- New MFA factor or verification flow: + - Start in `authentication/mfa/MfaApiClient.kt` + - Add/update MFA-specific models and exceptions in `authentication/mfa/` + +- New My Account API capability: + - Start in `myaccount/MyAccountAPIClient.kt` + - Add/update result models and exceptions in `myaccount/` + +- DPoP behavior or key management change: + - Start in `dpop/DPoP.kt` or `dpop/DPoPKeyStore.kt` + - Validate integration with `CredentialsManager`/`SecureCredentialsManager` refresh paths + +- New SDK-wide option affecting all requests: + - Start in `Auth0.kt` + - Validate propagation into networking/request creation paths + +### Testing Targets by Change Type + +- Auth API behavior: `auth0/src/test/java/com/auth0/android/authentication/` +- Web Auth/provider behavior: `auth0/src/test/java/com/auth0/android/provider/` +- Credentials/storage behavior: `auth0/src/test/java/com/auth0/android/authentication/storage/` +- MFA behavior: `auth0/src/test/java/com/auth0/android/authentication/mfa/` +- My Account behavior: `auth0/src/test/java/com/auth0/android/myaccount/` +- DPoP behavior: `auth0/src/test/java/com/auth0/android/dpop/` +- Request/response parsing: `auth0/src/test/java/com/auth0/android/request/` and `auth0/src/test/java/com/auth0/android/result/` + +## Security and Compatibility Notes + +- PKCE is a core OAuth defense and should remain enabled for relevant flows. +- DPoP support is present and has active maintenance; preserve nonce/retry correctness. +- Keystore/biometric paths must continue to fail safely with clear exception mapping. +- Redirect URI and scheme/app-link configuration must be exact to avoid browser callback failures. + +## Release and Versioning Workflow + +- `.version` is the source of truth for SDK versioning. +- `CHANGELOG.md` must be updated with user-facing changes. +- Release automation is defined in `.github/workflows/release.yml` and `.github/workflows/java-release.yml`. +- CI test workflow is defined in `.github/workflows/test.yml`. + +## Documentation to Keep in Sync + +- `README.md` for onboarding and requirements +- `EXAMPLES.md` for usage patterns +- `FAQ.md` for known pitfalls +- Migration guides (`V2_MIGRATION_GUIDE.md`, `V3_MIGRATION_GUIDE.md`, `V4_MIGRATION_GUIDE.md`) for breaking changes + +## Agent Dos and Don'ts + +### Do + +- Do preserve public API behavior unless the change is explicitly breaking and documented. +- Do maintain callback and coroutine parity when adding user-facing async APIs. +- Do add/update unit tests for both success and failure paths for every behavior change. +- Do keep changes focused to the feature area and follow existing package/class patterns. +- Do update `EXAMPLES.md` whenever adding a new feature or modifying existing feature behavior. +- Do keep `README.md` and other relevant docs in sync with user-visible behavior and API usage. +- Do validate security-sensitive flows carefully (PKCE, DPoP, keystore/biometric behavior, redirect handling). +- Do keep error handling typed and actionable, and map new failures to existing exception patterns. + +### Don't + +- Don't remove or weaken secure defaults (PKCE, token protections, keystore usage, DPoP checks). +- Don't introduce callback-only or coroutine-only APIs when both styles are already expected. +- Don't silently change token storage/refresh semantics without matching tests and documentation updates. +- Don't modify unrelated modules/files when implementing a scoped fix. +- Don't rely on brittle string matching of error messages in tests or SDK logic; use typed errors/codes. +- Don't bypass CI-relevant checks; run the repo's unit/lint workflow command before finalizing. +- Don't merge docs/code changes that drift from source-of-truth Gradle and workflow configs. + +## Agent Checklist Before Finishing a Change + +1. Confirm implementation follows existing SDK patterns (builder style, callbacks + coroutines where applicable). +2. Add/adjust tests and run the relevant Gradle tasks. +3. Validate no regression in public API behavior unless intended. +4. Update `README.md`, `EXAMPLES.md`, and other relevant docs when behavior or API shape changes. +5. Flag any security-sensitive changes explicitly in PR notes. diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c92be3f..ba123e114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,109 @@ # Change Log +## [4.0.0-beta.1](https://github.com/auth0/Auth0.Android/tree/4.0.0-beta.1) (2026-05-05) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.0.0-beta.0...4.0.0-beta.1) + +**⚠️ BREAKING CHANGES** +- breaking : Remove redundant constructors from SecureCredentialsManager [\#961](https://github.com/auth0/Auth0.Android/pull/961) ([pmathew92](https://github.com/pmathew92)) + +**Added** +- feat: Added Auth tab support [\#962](https://github.com/auth0/Auth0.Android/pull/962) ([pmathew92](https://github.com/pmathew92)) + +## [4.0.0-beta.0](https://github.com/auth0/Auth0.Android/tree/4.0.0-beta.0) (2026-04-20) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.15.0...4.0.0-beta.0) + +**⚠️ BREAKING CHANGES** +- Update the Min SDK version for the Auth0.Android SDK 26 [\#953](https://github.com/auth0/Auth0.Android/pull/953) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- breaking: removed the deprecated MFA APIs [\#947](https://github.com/auth0/Auth0.Android/pull/947) ([pmathew92](https://github.com/pmathew92)) +- breaking: Remove the Management API support [\#937](https://github.com/auth0/Auth0.Android/pull/937) ([pmathew92](https://github.com/pmathew92)) +- breaking: Moved the `useDPoP` method in the `WebAuthProvider` class to the login builder class [\#914](https://github.com/auth0/Auth0.Android/pull/914) ([pmathew92](https://github.com/pmathew92)) +- breaking: Removed the PasskeyProvider and the PasskeyManager class [\#909](https://github.com/auth0/Auth0.Android/pull/909) ([pmathew92](https://github.com/pmathew92)) + +**Added** +- feat: Add clearAll() API to credentials manager [\#951](https://github.com/auth0/Auth0.Android/pull/951) ([pmathew92](https://github.com/pmathew92)) +- feat: Add SSOCredentialsDeserializer for proper JSON deserialization of SSOCredentials [\#931](https://github.com/auth0/Auth0.Android/pull/931) ([pmathew92](https://github.com/pmathew92)) +- feat: Add Storage.removeAll(), default minTTL of 60s [\#918](https://github.com/auth0/Auth0.Android/pull/918) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- feat: Add support for ephemeral session for chrome custom tabs [\#916](https://github.com/auth0/Auth0.Android/pull/916) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- fix: Handle configuration changes during WebAuth flow to prevent memory leak [\#941](https://github.com/auth0/Auth0.Android/pull/941) ([utkrishtsahu](https://github.com/utkrishtsahu)) + +**Changed** +- refactor: Refactor DefaultClient from constructor-based to Builder pattern for configurable HTTP client options [\#910](https://github.com/auth0/Auth0.Android/pull/910) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- update: Gradle AGP 8 upgrade [\#900](https://github.com/auth0/Auth0.Android/pull/900) ([pmathew92](https://github.com/pmathew92)) +- update: Update dependencies [\#908](https://github.com/auth0/Auth0.Android/pull/908) ([utkrishtsahu](https://github.com/utkrishtsahu)) + +## [3.21.0](https://github.com/auth0/Auth0.Android/tree/3.21.0) (2026-07-08) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.20.0...3.21.0) + +**Added** +- feat: Add password enrollment to MyAccount API [\#1003](https://github.com/auth0/Auth0.Android/pull/1003) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- fix: MFAClient getAuthenticators filtering based on Authenticator.type field [\#998](https://github.com/auth0/Auth0.Android/pull/998) ([utkrishtsahu](https://github.com/utkrishtsahu)) + +## [3.20.0](https://github.com/auth0/Auth0.Android/tree/3.20.0) (2026-06-29) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.19.0...3.20.0) + +**Added** +- feat: Added DPoP support for MFA APIs [\#992](https://github.com/auth0/Auth0.Android/pull/992) ([pmathew92](https://github.com/pmathew92)) +- feat: enforce IPSIE session_expiry ceiling in credentials managers [\#983](https://github.com/auth0/Auth0.Android/pull/983) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- feat : Added passwordless API for DB-Connections [\#989](https://github.com/auth0/Auth0.Android/pull/989) ([pmathew92](https://github.com/pmathew92)) + +## [3.19.0](https://github.com/auth0/Auth0.Android/tree/3.19.0) (2026-06-15) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.18.0...3.19.0) + +**Added** +- feat: CTE delegation and impersonation support [\#975](https://github.com/auth0/Auth0.Android/pull/975) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- fix : Handling DPoP enabled WebAuth flow after process death [\#977](https://github.com/auth0/Auth0.Android/pull/977) ([pmathew92](https://github.com/pmathew92)) + +## [3.18.0](https://github.com/auth0/Auth0.Android/tree/3.18.0) (2026-05-26) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.17.0...3.18.0) + +**Added** +- feat : Add support to pass type parameter to the MyAccount list authentication method API [\#974](https://github.com/auth0/Auth0.Android/pull/974) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- fix : Guard against all uncaught exception in the serial executor block [\#970](https://github.com/auth0/Auth0.Android/pull/970) ([pmathew92](https://github.com/pmathew92)) + +## [3.17.0](https://github.com/auth0/Auth0.Android/tree/3.17.0) (2026-05-21) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.16.0...3.17.0) + +**Added** +- feat : Added additional properties support to passkeys signup API [\#969](https://github.com/auth0/Auth0.Android/pull/969) ([pmathew92](https://github.com/pmathew92)) +- feat :Add partial support for PAR auth flow [\#967](https://github.com/auth0/Auth0.Android/pull/967) ([pmathew92](https://github.com/pmathew92)) + +## [3.16.0](https://github.com/auth0/Auth0.Android/tree/3.16.0) (2026-04-27) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.15.0...3.16.0) + +**Added** +- feat : Add Partial Custom Tabs support (bottom sheet & side sheet) [\#954](https://github.com/auth0/Auth0.Android/pull/954) ([subhankarmaiti](https://github.com/subhankarmaiti)) + +## [3.15.0](https://github.com/auth0/Auth0.Android/tree/3.15.0) (2026-04-15) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.14.0...3.15.0) + +**Added** +- feat: Add three new DPoP errors to `CredentialsManagerException` [\#949](https://github.com/auth0/Auth0.Android/pull/949) ([pmathew92](https://github.com/pmathew92)) + +**Changed** +- refactor : Made the updateAuthenticationMethodById public [\#948](https://github.com/auth0/Auth0.Android/pull/948) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- refactor : Handling more exceptions scenario in DPoP key generation [\#943](https://github.com/auth0/Auth0.Android/pull/943) ([pmathew92](https://github.com/pmathew92)) + +## [3.14.0](https://github.com/auth0/Auth0.Android/tree/3.14.0) (2026-03-11) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.13.0...3.14.0) + +**Deprecated** +- refactor : Deprecate the existing MFA APIs in `AuthenticationAPIClient` [\#932](https://github.com/auth0/Auth0.Android/pull/932) ([pmathew92](https://github.com/pmathew92)) +- refactor : Deprecated the UsersAPIClient [\#930](https://github.com/auth0/Auth0.Android/pull/930) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- fix: Handle ProviderException in PKCS1→OAEP key migration to prevent saveCredentials() crash [\#924](https://github.com/auth0/Auth0.Android/pull/924) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- fix : Added the missing user agent to MyAccount and MFAApiClient [\#926](https://github.com/auth0/Auth0.Android/pull/926) ([pmathew92](https://github.com/pmathew92)) + ## [3.13.0](https://github.com/auth0/Auth0.Android/tree/3.13.0) (2026-02-06) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.12.2...3.13.0) diff --git a/EXAMPLES.md b/EXAMPLES.md index cae42b8ca..a4a409fc7 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -8,30 +8,37 @@ - [Specify Parameter](#specify-parameter) - [Specify a Custom Authorize URL](#specify-a-custom-authorize-url) - [Customize the Custom Tabs UI](#customize-the-custom-tabs-ui) + - [Partial Custom Tabs (Bottom Sheet and Side Sheet)](#partial-custom-tabs-bottom-sheet-and-side-sheet) - [Changing the Return To URL scheme](#changing-the-return-to-url-scheme) - [Specify a Custom Logout URL](#specify-a-custom-logout-url) - [Trusted Web Activity](#trusted-web-activity) - - [DPoP [EA]](#dpop-ea) + - [Ephemeral Browsing [Experimental]](#ephemeral-browsing-experimental) + - [Auth Tab [Experimental]](#auth-tab-experimental) + - [DPoP](#dpop) - [Authentication API](#authentication-api) - [Login with database connection](#login-with-database-connection) - - [Login using MFA with One Time Password code](#login-using-mfa-with-one-time-password-code) - [MFA Flexible Factors Grant](#mfa-flexible-factors-grant) - [Understanding the mfa_required Error Payload](#understanding-the-mfa_required-error-payload) - [Handling MFA Required Errors](#handling-mfa-required-errors) - [Getting Available Authenticators](#getting-available-authenticators) - [Enrolling New Authenticators](#enrolling-new-authenticators) - [Challenging an Authenticator](#challenging-an-authenticator) - - [Verifying MFA](#verifying-mfa) - [MFA Client Errors](#mfa-client-errors) - [Passwordless Login](#passwordless-login) - [Step 1: Request the code](#step-1-request-the-code) - [Step 2: Input the code](#step-2-input-the-code) + - [Passwordless Login with a Database Connection (EA)](#passwordless-login-with-a-database-connection-ea) + - [Step 1: Issue an OTP challenge](#step-1-issue-an-otp-challenge) + - [Step 2: Verify the code and log in](#step-2-verify-the-code-and-log-in) - [Sign Up with a database connection](#sign-up-with-a-database-connection) - [Get user information](#get-user-information) - [Custom Token Exchange](#custom-token-exchange) - - [Native to Web SSO login [EA]](#native-to-web-sso-login-ea) - - [DPoP [EA]](#dpop-ea-1) + - [Custom Token Exchange with Actor Token (Delegation/Impersonation)](#custom-token-exchange-with-actor-token-delegationimpersonation) + - [Native to Web SSO login](#native-to-web-sso-login) + - [Pushed Authorization Requests (PAR)](#pushed-authorization-requests-par) + - [DPoP](#dpop-1) - [My Account API](#my-account-api) + - [Using DPoP](#using-dpop) - [Enroll a new passkey](#enroll-a-new-passkey) - [Get Available Factors](#get-available-factors) - [Get All Enrolled Authentication Methods](#get-all-enrolled-authentication-methods) @@ -41,8 +48,10 @@ - [Enroll a TOTP (Authenticator App) Method](#enroll-a-totp-authenticator-app-method) - [Enroll a Push Notification Method](#enroll-a-push-notification-method) - [Enroll a Recovery Code](#enroll-a-recovery-code) + - [Enroll a Password Method](#enroll-a-password-method) - [Verify an Enrollment](#verify-an-enrollment) - [Delete an Authentication Method](#delete-an-authentication-method) + - [Update an Authentication Method](#update-an-authentication-method) - [Credentials Manager](#credentials-manager) - [Secure Credentials Manager](#secure-credentials-manager) - [Usage](#usage) @@ -51,11 +60,6 @@ - [Handling Credentials Manager exceptions](#handling-credentials-manager-exceptions) - [Passkeys](#passkeys) - [Bot Protection](#bot-protection) - - [Management API](#management-api) - - [Link users](#link-users) - - [Unlink users](#unlink-users) - - [Get User Profile](#get-user-profile) - - [Update User Metadata](#update-user-metadata) - [Token Validation](#token-validation) - [Organizations](#organizations) - [Log in to an organization](#log-in-to-an-organization) @@ -174,6 +178,75 @@ WebAuthProvider.login(account) ``` +### Partial Custom Tabs (Bottom Sheet and Side Sheet) + +You can present the authentication flow as a **bottom sheet** on compact screens or a **side sheet** on larger screens (e.g., tablets and foldables) instead of a full-screen browser tab. This is configured through `CustomTabsOptions`. + +> **Browser compatibility:** +> - **Bottom sheet** (Partial Custom Tabs) requires **Chrome 107+** (or another Custom Tabs browser that supports the Partial Custom Tabs protocol). +> - **Side sheet** requires **Chrome 120+** (or another browser that supports side-sheet Custom Tabs). +> +> If the user's browser does not meet the minimum version requirement, the authentication flow automatically falls back to a standard full-screen Custom Tab (or a full-screen browser tab if Custom Tabs are unsupported). It is therefore safe to enable these options unconditionally — users on older browsers will simply see the full-screen experience. + +#### Bottom sheet + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) // initial height in dp + .withResizable(true) // allow the user to drag to resize (default) + .withToolbarCornerRadius(16) // rounded top corners (0–16 dp) + .build() + +WebAuthProvider.login(account) + .withCustomTabsOptions(ctOptions) + .start(this, callback) +``` + +#### Side sheet (with bottom-sheet fallback on narrow screens) + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) // used when the screen is narrower than the breakpoint + .withInitialWidth(500) // initial side-sheet width in dp + .withSideSheetBreakpoint(840) // screens wider than this render as a side sheet + .build() + +WebAuthProvider.login(account) + .withCustomTabsOptions(ctOptions) + .start(this, callback) +``` + +If `withSideSheetBreakpoint` is not set, the browser's default breakpoint (typically 840 dp in Chrome) applies, so devices narrower than that will continue to render as a bottom sheet or full screen. + +#### Allow interaction with the app behind the partial tab + +By default, the app behind a Partial Custom Tab is non-interactive. Enable pass-through interaction with: + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) + .withBackgroundInteractionEnabled(true) + .build() +``` + +
+ Using Java + +```java +CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(700) + .withInitialWidth(500) + .withSideSheetBreakpoint(840) + .withToolbarCornerRadius(16) + .withBackgroundInteractionEnabled(true) + .build(); + +WebAuthProvider.login(account) + .withCustomTabsOptions(options) + .start(MainActivity.this, callback); +``` +
+ ## Changing the Return To URL scheme This configuration will probably match what you've done for the [authentication setup](#a-note-about-app-deep-linking). @@ -228,17 +301,123 @@ WebAuthProvider.login(account) .await(this) ``` -## DPoP [EA] +> [!NOTE] +> `withTrustedWebActivity()` and `withAuthTab()` are mutually exclusive. If both are set on the same builder, TWA takes precedence and Auth Tab will not be used. They rely on different underlying launch mechanisms and cannot be combined. For standard OAuth flows against Auth0, prefer [Auth Tab](#auth-tab-experimental) — it requires no server-side setup and works with any domain. + +## Ephemeral Browsing [Experimental] + +> **WARNING** +> Ephemeral browsing support in Auth0.Android is still experimental and can change in the future. Please test it thoroughly in all the targeted browsers +> and OS variants and let us know your feedback. + +Ephemeral browsing launches the Chrome Custom Tab in a fully isolated session — cookies, cache, history, and credentials are deleted when the tab closes. This is equivalent to incognito/private mode for Custom Tabs, useful for privacy-focused authentication flows. + +Requires Chrome 136+ or a compatible browser. On unsupported browsers, the SDK falls back to a regular Custom Tab and logs a warning. + +```kotlin +WebAuthProvider.login(account) + .withEphemeralBrowsing() + .start(this, callback) +``` + +
+Using async/await + +```kotlin +WebAuthProvider.login(account) + .withEphemeralBrowsing() + .await(this) +``` +
+ +
+ Using Java + +```java +WebAuthProvider.login(account) + .withEphemeralBrowsing() + .start(this, callback); +``` +
+ +## Auth Tab [Experimental] + +> **WARNING** +> Auth Tab support in Auth0.Android is still experimental and can change in the future. Please test it thoroughly on all targeted devices and OS variants and let us know your feedback. + +Auth Tab uses [`AuthTabIntent`](https://developer.android.com/reference/androidx/browser/auth/AuthTabIntent) from `androidx.browser` to open the authentication flow in a dedicated browser tab that verifies the redirect URI scheme before delivering the result back to your app. This provides an additional layer of security by ensuring only your app — whose redirect URI scheme is verified at registration time — can receive the authentication callback, preventing other apps from intercepting it. + +Requires `androidx.browser` 1.9.0+ and a browser that supports Auth Tab on the device. On unsupported browsers, the SDK automatically falls back to a regular Custom Tab. + +> [!NOTE] +> `withAuthTab()` and `withTrustedWebActivity()` are mutually exclusive. If both are set on the same builder, TWA takes precedence and Auth Tab will not be used. They rely on different underlying launch mechanisms and cannot be combined. + +```kotlin +WebAuthProvider.login(account) + .withAuthTab() + .start(this, callback) +``` + +
+Using async/await + +```kotlin +WebAuthProvider.login(account) + .withAuthTab() + .await(this) +``` +
+ +
+ Using Java + +```java +WebAuthProvider.login(account) + .withAuthTab() + .start(this, callback); +``` +
+ +Auth Tab can also be used for logout: + +```kotlin +WebAuthProvider.logout(account) + .withAuthTab() + .start(this, logoutCallback) +``` + +
+ Using Java + +```java +WebAuthProvider.logout(account) + .withAuthTab() + .start(this, logoutCallback); +``` +
+ +### Limitations with `CustomTabsOptions` + +When `withAuthTab()` is combined with `withCustomTabsOptions()`, only a subset of options take effect. Auth Tab uses a separate intent builder (`AuthTabIntent`) and is always presented full-screen. -> [!NOTE] -> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. +| Option | Supported | +|---|---| +| `withToolbarColor()` | ✅ Applied to the Auth Tab toolbar | +| `showTitle()` | ❌ Ignored — Auth Tab has no title-visibility option | +| `withEphemeralBrowsing()` | ❌ Ignored — Auth Tab does not support ephemeral sessions. Use a regular Custom Tab if session isolation is required | +| `withInitialHeight()` / `withInitialWidth()` | ❌ Ignored — Auth Tab is always full-screen | +| `withToolbarCornerRadius()` | ❌ Ignored | +| `withSideSheetBreakpoint()` | ❌ Ignored | +| `withBackgroundInteractionEnabled()` | ❌ Ignored | -[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP()` method. +## DPoP + +[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context)` method on the login Builder. ```kotlin WebAuthProvider - .useDPoP() .login(account) + .useDPoP(requireContext()) .start(requireContext(), object : Callback { override fun onSuccess(result: Credentials) { println("Credentials $result") @@ -282,6 +461,21 @@ if (DPoP.isNonceRequiredError(response)) { } ``` +When using DPoP with `CredentialsManager` or `SecureCredentialsManager`, the `AuthenticationAPIClient` passed to the credentials manager **must** also have DPoP enabled. Otherwise, token refresh requests will be sent without the DPoP proof and the SDK will throw a `CredentialsManagerException.DPOP_NOT_CONFIGURED` error. + +```kotlin + +val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") +val apiClient = AuthenticationAPIClient(auth0).useDPoP(context) // DPoP enabled +val storage = SharedPreferencesStorage(context) +val manager = CredentialsManager(apiClient, storage) + +WebAuthProvider + .useDPoP() + .login(auth0) + .start(context, callback) +``` + On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain. ```kotlin @@ -295,9 +489,59 @@ WebAuthProvider.logout(account) }) ``` -> [!NOTE] +> [!NOTE] > DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. +## Handling Configuration Changes During Authentication + +When the Activity is destroyed during authentication due to a configuration change (e.g. device rotation, locale change, dark mode toggle), the SDK caches the authentication result internally. Call `WebAuthProvider.registerCallbacks()` once in your `onCreate()` to recover it. This single call handles both recovery scenarios: + +- **Configuration change**: delivers any cached result on the next `onResume` to the callback +- **Process death**: `AuthenticationActivity` restores OAuth state and processes the redirect. Since static state was wiped, the result is cached and delivered to `loginCallback` on the next `onResume` after `registerCallbacks()` is called + +```kotlin +class LoginActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WebAuthProvider.registerCallbacks( + lifecycleOwner = this, + loginCallback = object : Callback { + override fun onSuccess(result: Credentials) { + // Handle successful login + } + override fun onFailure(error: AuthenticationException) { + // Handle error + } + }, + logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { + // Handle successful logout + } + override fun onFailure(error: AuthenticationException) { + // Handle error + } + } + ) + } + + fun onLoginClick() { + WebAuthProvider.login(account) + .withScheme("demo") + .start(this, loginCallback) + } + + fun onLogoutClick() { + WebAuthProvider.logout(account) + .withScheme("demo") + .start(this, logoutCallback) + } +} +``` + +> [!NOTE] +> If you use the `suspend fun await()` API from a ViewModel coroutine scope, the Activity is never captured in the callback chain, so you do not need `registerCallbacks()` calls. + ## Authentication API The client provides methods to authenticate the user against the Auth0 server. @@ -370,62 +614,6 @@ authentication > The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. -### Login using MFA with One Time Password code - -This call requires the client to have the *MFA* Client Grant Type enabled. Check [this article](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. - -When you sign in to a multifactor authentication enabled connection using the `login` method, you receive an error standing that MFA is required for that user along with an `mfa_token` value. Use this value to call `loginWithOTP` and complete the MFA flow passing the One Time Password from the enrolled MFA code generator app. - -```kotlin -authentication - .loginWithOTP("the mfa token", "123456") - .validateClaims() //mandatory - .start(object: Callback { - override fun onFailure(exception: AuthenticationException) { } - - override fun onSuccess(credentials: Credentials) { } - }) -``` - -
- Using coroutines - -```kotlin -try { - val credentials = authentication - .loginWithOTP("the mfa token", "123456") - .validateClaims() - .await() - println(credentials) -} catch (e: AuthenticationException) { - e.printStacktrace() -} -``` -
- -
- Using Java - -```java -authentication - .loginWithOTP("the mfa token", "123456") - .validateClaims() //mandatory - .start(new Callback() { - @Override - public void onSuccess(@Nullable Credentials payload) { - //Logged in! - } - - @Override - public void onFailure(@NonNull AuthenticationException error) { - //Error! - } - }); -``` -
- -> The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. - ### MFA Flexible Factors Grant > [!IMPORTANT] @@ -470,7 +658,7 @@ authentication requirements?.enroll?.let { enrollTypes -> println("User needs to enroll MFA") println("Available enrollment types: ${enrollTypes.map { it.type }}") - // Example output: ["otp", "sms", "push-notification"] + // Example output: ["otp", "phone", "push-notification"] // Proceed with MFA enrollment using one of these types } @@ -478,7 +666,7 @@ authentication requirements?.challenge?.let { challengeTypes -> println("User has enrolled MFA factors") println("Available challenge types: ${challengeTypes.map { it.type }}") - // Example output: ["otp", "sms"] + // Example output: ["otp", "phone"] // Get authenticators and challenge one of them } @@ -560,14 +748,14 @@ try { requirements?.enroll?.let { enrollTypes -> println("User needs to enroll MFA") println("Available enrollment types: ${enrollTypes.map { it.type }}") - // Example output: ["otp", "sms", "push-notification"] + // Example output: ["otp", "phone", "push-notification"] } // Check if challenge is available requirements?.challenge?.let { challengeTypes -> println("User has enrolled MFA factors") println("Available challenge types: ${challengeTypes.map { it.type }}") - // Example output: ["otp", "sms"] + // Example output: ["otp", "phone"] } // Proceed with MFA flow using mfaToken @@ -592,6 +780,32 @@ MfaApiClient mfaClient = authentication.mfaClient(mfaToken); ``` +##### Using DPoP with MFA + +If the originating `AuthenticationAPIClient` has [DPoP](#dpop) enabled, the resulting `mfaClient` inherits it automatically, and the final `verify()` call exchanging credentials at `/oauth/token` will carry a DPoP proof: + +```kotlin +val authentication = AuthenticationAPIClient(account).useDPoP(context) +val mfaClient = authentication.mfaClient(mfaToken) // DPoP inherited +``` + +Alternatively, if you are using the `MfaApiClient` on its own, enable DPoP directly on it: + +```kotlin +val mfaClient = MfaApiClient(account, mfaToken).useDPoP(context) +``` + +
+ Using Java + +```java +MfaApiClient mfaClient = new MfaApiClient(account, mfaToken).useDPoP(context); +``` +
+ +> [!NOTE] +> The proof is only attached to the token exchange performed by `verify()`. The `getAuthenticators()`, `enroll()`, and `challenge()` calls authenticate with the MFA token as a bearer credential and do not carry a DPoP proof. + #### Getting Available Authenticators Retrieve the list of authenticators that the user has enrolled and are allowed for this authentication flow. The `factorsAllowed` parameter filters the authenticators based on the allowed factor types from the MFA requirements. @@ -752,10 +966,13 @@ mfaClient override fun onFailure(exception: MfaEnrollmentException) { } override fun onSuccess(enrollment: EnrollmentChallenge) { - // Display QR code or secret for user to scan/enter in authenticator app + // Display QR code or secret for user to scan/enter in authenticator app. + // The `/mfa/associate` endpoint returns the manual-entry key as `secret` + // (not `manualInputCode`, which is only populated by the My Account API). if (enrollment is TotpEnrollmentChallenge) { - val secret = enrollment.manualInputCode + val secret = enrollment.secret val barcodeUri = enrollment.barcodeUri + val recoveryCodes = enrollment.recoveryCodes } } }) @@ -773,11 +990,14 @@ mfaClient @Override public void onSuccess(EnrollmentChallenge enrollment) { - // Display QR code or secret for user to scan/enter in authenticator app + // Display QR code or secret for user to scan/enter in authenticator app. + // The `/mfa/associate` endpoint returns the manual-entry key as `secret` + // (not `manualInputCode`, which is only populated by the My Account API). if (enrollment instanceof TotpEnrollmentChallenge) { TotpEnrollmentChallenge totpEnrollment = (TotpEnrollmentChallenge) enrollment; - String secret = totpEnrollment.getManualInputCode(); + String secret = totpEnrollment.getSecret(); String barcodeUri = totpEnrollment.getBarcodeUri(); + List recoveryCodes = totpEnrollment.getRecoveryCodes(); } } }); @@ -1401,12 +1621,63 @@ authentication > The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. -### Sign Up with a database connection +### Passwordless Login with a Database Connection (EA) + +> [!IMPORTANT] +> Passwordless Login for database connections is currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. + +This flow lets users authenticate with a one-time code sent over email or SMS/voice against a **database connection** that has `email_otp` or `phone_otp` enabled. It is distinct from the `/passwordless/start` flow described above, which uses dedicated passwordless connections. + +Obtain a `PasswordlessClient` from the `AuthenticationAPIClient`: ```kotlin -authentication - .signUp("info@auth0.com", "a secret password", "my-database-connection") - .validateClaims() //mandatory +val passwordless = AuthenticationAPIClient(account).passwordlessClient() +``` + +The flow has two steps: first issue an OTP challenge, then — after the user enters the code they received — exchange it for credentials. **Save the `PasswordlessChallenge` from step 1**, as you pass that same object into `loginWithOTP` in step 2. + +#### Step 1: Issue an OTP challenge + +Send a one-time code to the user's email. For privacy, the server **always responds successfully regardless of whether the user exists**. On success, save the returned `PasswordlessChallenge` for step 2. + +```kotlin +// keep this reference until the user enters the code +var challenge: PasswordlessChallenge? = null + +passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection") + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(result: PasswordlessChallenge) { + challenge = result + } + }) +``` + +To send the code over SMS or voice instead, use `challengeWithPhoneNumber` against a connection with `phone_otp` enabled, choosing the `DeliveryMethod`: + +```kotlin +passwordless + .challengeWithPhoneNumber("+15555550123", "my-database-connection", DeliveryMethod.TEXT) + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(result: PasswordlessChallenge) { + challenge = result + } + }) +``` + +Both challenge methods accept an optional `allowSignup` parameter (defaults to `false`) that controls whether a new user is created if one does not yet exist. + +#### Step 2: Verify the code and log in + +Once the user enters the code, pass the saved `challenge` together with that code to `loginWithOTP` to obtain `Credentials`. If DPoP is enabled on the originating `AuthenticationAPIClient`, a DPoP proof is attached automatically to this token request. + +```kotlin +passwordless + .loginWithOTP(challenge, "123456") .start(object: Callback { override fun onFailure(exception: AuthenticationException) { } @@ -1418,14 +1689,81 @@ authentication Using coroutines ```kotlin -try { - val credentials = authentication - .signUp("info@auth0.com", "a secret password", "my-database-connection") - .validateClaims() - .await() - println(credentials) -} catch (e: AuthenticationException) { - e.printStacktrace() +// Step 1: issue the challenge and keep it +val challenge = passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection") + .await() + +// Step 2: once the user enters the code, pass the saved challenge back to log in +val credentials = passwordless + .loginWithOTP(challenge, "123456") + .await() +``` + + +
+ Using Java + +```java +// Step 1: issue the challenge and keep it +passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection", false) + .start(new Callback() { + @Override + public void onSuccess(PasswordlessChallenge result) { + challenge = result; + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + //Error! + } + }); + +// Step 2: once the user enters the code, pass the saved challenge back to log in +passwordless + .loginWithOTP(challenge, "123456") + .start(new Callback() { + @Override + public void onSuccess(@Nullable Credentials payload) { + //Logged in! + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + //Error! + } + }); +``` +
+ +> The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. + +### Sign Up with a database connection + +```kotlin +authentication + .signUp("info@auth0.com", "a secret password", "my-database-connection") + .validateClaims() //mandatory + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(credentials: Credentials) { } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val credentials = authentication + .signUp("info@auth0.com", "a secret password", "my-database-connection") + .validateClaims() + .await() + println(credentials) +} catch (e: AuthenticationException) { + e.printStacktrace() } ```
@@ -1551,12 +1889,98 @@ authentication +#### Custom Token Exchange with Actor Token (Delegation/Impersonation) -## Native to Web SSO login [EA] +For delegation or impersonation scenarios where one principal acts on behalf of another (e.g., an AI agent acting on behalf of a user), pass `ActorToken` with the actor token details: -> [!NOTE] -> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it -> enabled for your tenant. +> **Note:** When `actor_token` is present in the request, Auth0 will not issue a refresh token regardless of whether `offline_access` is in the scope. The `Credentials.refreshToken` will be `null` in this flow. + +```kotlin +import com.auth0.android.authentication.request.ActorToken + +val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:my-org:actor-token-type" +) + +authentication + .customTokenExchange( + subjectTokenType = "http://my-org/custom-token", + subjectToken = "subject-token-value", + organization = "org_12345", + actorToken = actorToken + ) + .start(object : Callback { + override fun onSuccess(result: Credentials) { + // Access the actor claim from the ID token + val actor = result.user.actor + if (actor != null) { + println("Actor sub: ${actor.sub}") + println("Actor properties: ${actor.extraProperties}") + // Nested delegation chain (if present) + val nestedActor = actor.actor + } + } + + override fun onFailure(exception: AuthenticationException) { + // Handle error + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:my-org:actor-token-type" + ) + val credentials = authentication + .customTokenExchange( + subjectTokenType = "http://my-org/custom-token", + subjectToken = "subject-token-value", + actorToken = actorToken + ) + .await() + // Access the actor claim + val actor = credentials.user.actor +} catch (e: AuthenticationException) { + e.printStackTrace() +} +``` +
+ +
+ Using Java + +```java +ActorToken actorToken = new ActorToken( + "actor-token-value", + "urn:my-org:actor-token-type" +); + +authentication + .customTokenExchange("http://my-org/custom-token", "subject-token-value", null, actorToken) + .start(new Callback() { + @Override + public void onSuccess(@Nullable Credentials payload) { + ActorClaim actor = payload.getUser().getActor(); + if (actor != null) { + Log.d("CTE", "Actor: " + actor.getSub()); + } + } + @Override + public void onFailure(@NonNull AuthenticationException error) { + // Handle error + } + }); +``` +
+ + +## Native to Web SSO login This feature allows you to authenticate a user in a web session using the refresh token obtained from the native session without requiring the user to log in again. @@ -1610,10 +2034,76 @@ authentication ``` -## DPoP [EA] +## Pushed Authorization Requests (PAR) + +This feature handles the browser authorization step of a [PAR (RFC 9126)](https://www.rfc-editor.org/rfc/rfc9126.html) flow. It opens the `/authorize` endpoint with a `request_uri` obtained from your backend's PAR endpoint call, and returns the authorization code for your backend to exchange for tokens. + +> [!IMPORTANT] +> Auth0 only supports PAR for **confidential clients**. Since mobile apps are public clients, the `/oauth/par` and `/oauth/token` calls must be made by your backend (BFF - Backend for Frontend). The SDK only handles opening the browser with the `request_uri` and returning the resulting authorization code. +> +> Your Auth0 application configured in the SDK should use the **same client_id** as the one your backend uses when calling the `/oauth/par` endpoint. + +```kotlin +WebAuthProvider.authorizeWithRequestUri(account) + .start(context, requestUri, object : Callback { + override fun onSuccess(result: AuthorizationCode) { + // Send result.code to your BFF for token exchange + // Validate result.state against the state your BFF used in the PAR request + } + + override fun onFailure(exception: AuthenticationException) { + // Handle error + } + }) +``` + +> [!NOTE] +> The SDK does not validate the `state` parameter. The `state` is generated by your BFF when calling `/oauth/par` and returned as-is in `AuthorizationCode.state`. Your app or BFF **must** validate that the returned `state` matches the original value to prevent CSRF attacks. + +
+ Using coroutines + +```kotlin +try { + val authCode = WebAuthProvider.authorizeWithRequestUri(account) + .await(context, requestUri) + // Send authCode.code to your BFF for token exchange + // Validate authCode.state against the state your BFF used in the PAR request +} catch (e: AuthenticationException) { + e.printStackTrace() +} +``` +
+ +
+ Using Java + +```java +WebAuthProvider.authorizeWithRequestUri(account) + .start(context, requestUri, new Callback() { + @Override + public void onSuccess(@NonNull AuthorizationCode result) { + // Send result.getCode() to your BFF for token exchange + // Validate result.getState() against the state your BFF used in the PAR request + } + + @Override + public void onFailure(@NonNull AuthenticationException exception) { + // Handle error + } + }); +``` +
+ +You can also pass a session transfer token to enable web SSO by transferring an existing native session to the browser: -> [!NOTE] -> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. +```kotlin +WebAuthProvider.authorizeWithRequestUri(account) + .withSessionTransferToken(sessionTransferToken) + .await(context, requestUri) +``` + +## DPoP [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context: Context)` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client. @@ -1668,24 +2158,55 @@ To use DPoP with `SecureCredentialsManager` you need to pass an instance of the val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") val apiClient = AuthenticationAPIClient(auth0).useDPoP(this) val storage = SharedPreferencesStorage(this) -val manager = SecureCredentialsManager(apiClient, this, auth0, storage) +val manager = SecureCredentialsManager(apiClient, this, storage) +``` +Similarly, for `CredentialsManager`: + +```kotlin +val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") +val apiClient = AuthenticationAPIClient(auth0).useDPoP(this) +val storage = SharedPreferencesStorage(this) +val manager = CredentialsManager(apiClient, storage) ``` +> [!IMPORTANT] +> When credentials are DPoP-bound, the SDK validates the DPoP key state before each token refresh. If the DPoP key pair is lost, the SDK will throw `CredentialsManagerException.DPOP_KEY_MISSING` and the user must re-authenticate. If the key pair has changed since the credentials were saved, the SDK will throw `CredentialsManagerException.DPOP_KEY_MISMATCH`. If the `AuthenticationAPIClient` was not configured with `useDPoP()`, the SDK will throw `CredentialsManagerException.DPOP_NOT_CONFIGURED`. -> [!NOTE] +> [!NOTE] > DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. ## My Account API -> [!NOTE] -> The My Account API is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. - Use the Auth0 My Account API to manage the current user's account. -To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials [EA]](#api-credentials-ea) to learn how to obtain one. +To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials](#api-credentials) to learn how to obtain one. + +```kotlin +val client = MyAccountAPIClient(auth0, accessToken) +``` + +#### Using DPoP + +If your application uses [DPoP (Demonstrating Proof of Possession)](https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-dpop), you can enable it on the My Account API client: + +```kotlin +val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) +``` + +When DPoP is enabled, the client will automatically: +- Use the `DPoP` authorization scheme instead of `Bearer` +- Include a DPoP proof header on every request + +
+ Using Java + +```java +MyAccountAPIClient client = new MyAccountAPIClient(auth0, accessToken).useDPoP(context); +``` +
### Enroll a new passkey @@ -1894,7 +2415,7 @@ myAccountClient.getFactors() ### Get All Enrolled Authentication Methods **Scopes required:** `read:me:authentication_methods` -Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. +Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. You can optionally filter the results by type using `AuthenticationMethodType`. **Prerequisites:** @@ -1902,10 +2423,20 @@ Retrieves a detailed list of all the authentication methods that the current use The user must have one or more authentication methods already enrolled. ```kotlin +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(object : Callback, MyAccountException> { - override fun onSuccess(result: AuthenticationMethods) { - // List of enrolled methods in result.authenticationMethods + override fun onSuccess(result: List) { + // List of enrolled methods + } + override fun onFailure(error: MyAccountException) { } + }) + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(object : Callback, MyAccountException> { + override fun onSuccess(result: List) { + // List of enrolled passkey methods only } override fun onFailure(error: MyAccountException) { } }) @@ -1914,11 +2445,23 @@ myAccountClient.getAuthenticationMethods() Using Java ```java +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(new Callback, MyAccountException>() { @Override - public void onSuccess(AuthenticationMethods result) { - // List of enrolled methods in result.getAuthenticationMethods() + public void onSuccess(List result) { + // List of enrolled methods + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(new Callback, MyAccountException>() { + @Override + public void onSuccess(List result) { + // List of enrolled passkey methods only } @Override public void onFailure(@NonNull MyAccountException error) { } @@ -2156,6 +2699,69 @@ myAccountClient.enrollRecoveryCode() ``` +### Enroll a Password Method +**Scopes required:** `create:me:authentication_methods` + +Enrolling a password authentication method is a two-step process. First, you request an enrollment challenge, which returns the connection's password [policy](https://auth0.com/docs/authenticate/database-connections/password-options) so you can guide the user to choose a compliant password. Then, you confirm the enrollment with the new password. + +#### 1. Request an enrollment challenge + +```kotlin +myAccountClient.enrollPassword() + .start(object : Callback { + override fun onSuccess(result: PasswordEnrollmentChallenge) { + // Use result.policy to validate the user's new password before confirming. + // Then use result.id and result.authSession to verify. + } + override fun onFailure(error: MyAccountException) { } + }) +``` + +
+ Using Java + +```java +myAccountClient.enrollPassword() + .start(new Callback() { + @Override + public void onSuccess(PasswordEnrollmentChallenge result) { + // Use result.getPolicy() to validate the user's new password before confirming. + // Then use result.getId() and result.getAuthSession() to verify. + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +#### 2. Confirm the enrollment + +```kotlin +myAccountClient.verifyPassword("challenge_id_from_enroll", "auth_session_from_enroll", "new_password") + .start(object : Callback { + override fun onSuccess(result: PasswordAuthenticationMethod) { + // Enrollment successful + } + override fun onFailure(error: MyAccountException) { } + }) +``` + +
+ Using Java + +```java +myAccountClient.verifyPassword("challenge_id_from_enroll", "auth_session_from_enroll", "new_password") + .start(new Callback() { + @Override + public void onSuccess(PasswordAuthenticationMethod result) { + // Enrollment successful + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ ### Verify an Enrollment **Scopes required:** `create:me:authentication_methods` @@ -2247,6 +2853,42 @@ myAccountClient.deleteAuthenticationMethod("phone|dev_...") + +### Update an Authentication Method +**Scopes required:** `update:me:authentication_methods` + +Updates a single authentication method. + +**Prerequisites:** + +The user must have the specific authentication method (identified by its ID) already enrolled. + +```kotlin +myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") + .start(object : Callback { + override fun onSuccess(result: Unit) { + // Deletion successful + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") + .start(new Callback() { + @Override + public void onSuccess(Void result) { + // Deletion successful + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ + ## Credentials Manager ### Secure Credentials Manager @@ -2254,31 +2896,34 @@ myAccountClient.deleteAuthenticationMethod("phone|dev_...") This version adds encryption to the data storage. Additionally, in those devices where a Secure Lock Screen has been configured it can require the user to authenticate before letting them obtain the stored credentials. The class is called `SecureCredentialsManager`. #### Usage -The usage is similar to the previous version, with the slight difference that the manager now requires a valid android `Context` as shown below: + +`SecureCredentialsManager` requires an `AuthenticationAPIClient` instance. The manager uses the supplied client for all token renewals and DPoP-bound refreshes, so configure that client first and then pass the same instance into `SecureCredentialsManager`. Create one from your `Auth0` configuration object and pass it to the manager: ```kotlin +val account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") +val apiClient = AuthenticationAPIClient(account) val storage = SharedPreferencesStorage(this) -val manager = SecureCredentialsManager(this, account, storage) +val manager = SecureCredentialsManager(apiClient, this, storage) ```
Using Java ```java +Auth0 account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); +AuthenticationAPIClient apiClient = new AuthenticationAPIClient(account); Storage storage = new SharedPreferencesStorage(this); -SecureCredentialsManager manager = new SecureCredentialsManager(this, account, storage); +SecureCredentialsManager manager = new SecureCredentialsManager(apiClient, this, storage); ```
-#### Using a Custom AuthenticationAPIClient - -If you need to configure the `AuthenticationAPIClient` with advanced features (such as DPoP), you can pass your own configured instance to `SecureCredentialsManager`: +To configure the `AuthenticationAPIClient` with advanced features such as DPoP, set them up on the client before passing it in: ```kotlin val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") val apiClient = AuthenticationAPIClient(auth0).useDPoP(this) val storage = SharedPreferencesStorage(this) -val manager = SecureCredentialsManager(apiClient, this, auth0, storage) +val manager = SecureCredentialsManager(apiClient, this, storage) ```
@@ -2288,7 +2933,7 @@ val manager = SecureCredentialsManager(apiClient, this, auth0, storage) Auth0 auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); AuthenticationAPIClient apiClient = new AuthenticationAPIClient(auth0).useDPoP(this); Storage storage = new SharedPreferencesStorage(this); -SecureCredentialsManager manager = new SecureCredentialsManager(apiClient, this, auth0, storage); +SecureCredentialsManager manager = new SecureCredentialsManager(apiClient, this, storage); ```
@@ -2305,9 +2950,11 @@ val localAuthenticationOptions = .setDeviceCredentialFallback(true) .setPolicy(BiometricPolicy.Session(300)) // Optional: Use session-based policy (5 minutes) .build() +val account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") +val apiClient = AuthenticationAPIClient(account) val storage = SharedPreferencesStorage(this) val manager = SecureCredentialsManager( - this, account, storage, fragmentActivity, + apiClient, this, storage, fragmentActivity, localAuthenticationOptions ) ``` @@ -2322,9 +2969,11 @@ LocalAuthenticationOptions localAuthenticationOptions = .setDeviceCredentialFallback(true) .setPolicy(new BiometricPolicy.Session(300)) // Optional: Use session-based policy (5 minutes) .build(); +Auth0 account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); +AuthenticationAPIClient apiClient = new AuthenticationAPIClient(account); Storage storage = new SharedPreferencesStorage(context); SecureCredentialsManager secureCredentialsManager = new SecureCredentialsManager( - context, auth0, storage, fragmentActivity, + apiClient, context, storage, fragmentActivity, localAuthenticationOptions); ``` @@ -2345,7 +2994,7 @@ val localAuthenticationOptions = .build() val storage = SharedPreferencesStorage(this) val manager = SecureCredentialsManager( - apiClient, this, auth0, storage, fragmentActivity, + apiClient, this, storage, fragmentActivity, localAuthenticationOptions ) ``` @@ -2367,7 +3016,7 @@ LocalAuthenticationOptions localAuthenticationOptions = .build(); Storage storage = new SharedPreferencesStorage(this); SecureCredentialsManager secureCredentialsManager = new SecureCredentialsManager( - apiClient, this, auth0, storage, fragmentActivity, + apiClient, this, storage, fragmentActivity, localAuthenticationOptions); ``` @@ -2478,10 +3127,7 @@ val isValid = secureCredentialsManager.isBiometricSessionValid() ### Other Credentials -#### API credentials [EA] - -> [!NOTE] -> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. +#### API credentials When the user logs in, you can request an access token for a specific API by passing its API identifier as the [audience](#specify-audience) value. The access token in the resulting credentials can then be used to make authenticated requests to that API. @@ -2558,28 +3204,90 @@ In the event that something happened while trying to save or retrieve the creden - Tokens have expired but no `refresh_token` is available to perform a refresh credentials request. - Device's Lock Screen security settings have changed (e.g. the PIN code was changed). Even when `hasCredentials` returns true, the encryption keys will be deemed invalid and until `saveCredentials` is called again it won't be possible to decrypt any previously existing content, since they keys used back then are not the same as the new ones. - Device is not compatible with some of the algorithms required by the `SecureCredentialsManager` class. This is considered a catastrophic event and might happen when the OEM has modified the Android ROM removing some of the officially included algorithms. Nevertheless, it can be checked in the exception instance itself by calling `isDeviceIncompatible`. By doing so you can decide the fallback for storing the credentials, such as using the regular `CredentialsManager`. +- **DPoP key pair lost** — The DPoP key pair is no longer available in the Android KeyStore. The stored credentials are cleared and re-authentication is required. +- **DPoP key pair mismatch** — The DPoP key pair exists but is different from the one used when the credentials were saved. The stored credentials are cleared and re-authentication is required. +- **DPoP not configured** — The stored credentials are DPoP-bound but the `AuthenticationAPIClient` used by the credentials manager was not configured with `useDPoP(context)`. The developer needs to call `AuthenticationAPIClient(auth0).useDPoP(context)` and pass the configured client to the credentials manager. +- **Session expired** — The session has reached the `session_expiry` ceiling asserted by the upstream identity provider. The stored credentials are cleared and re-authentication is required. See [Upstream session expiry](#upstream-session-expiry) below. -You can access the `code` property of the `CredentialsManagerException` to understand why the operation with `CredentialsManager` has failed and the `message` property of the `CredentialsManagerException` would give you a description of the exception. +You can access the `code` property of the `CredentialsManagerException` to understand why the operation with `CredentialsManager` has failed and the `message` property of the `CredentialsManagerException` would give you a description of the exception. -Starting from version `3.0.0` you can even pass the exception to a `when` expression and handle the exception accordingly in your app's logic as shown in the below code snippet: +Starting from version `3.0.0` you can even pass the exception to a `when` expression and handle the exception accordingly in your app's logic as shown in the below code snippet: ```kotlin when(credentialsManagerException) { - CredentialsManagerException.NO_CREDENTIALS - > { + CredentialsManagerException.NO_CREDENTIALS -> { // handle no credentials scenario } - CredentialsManagerException.NO_REFRESH_TOKEN - > { + CredentialsManagerException.NO_REFRESH_TOKEN -> { // handle no refresh token scenario } - CredentialsManagerException.STORE_FAILED - > { + CredentialsManagerException.STORE_FAILED -> { // handle store failed scenario } + + CredentialsManagerException.DPOP_KEY_MISSING -> { + // DPoP key was lost + // Clear local state and prompt user to re-authenticate + } + + CredentialsManagerException.DPOP_KEY_MISMATCH -> { + // DPoP key exists but doesn't match the one used at login (key rotation) + // Clear local state and prompt user to re-authenticate + } + + CredentialsManagerException.DPOP_NOT_CONFIGURED -> { + // Developer forgot to call useDPoP() on the AuthenticationAPIClient + // passed to the credentials manager. Fix the client configuration. + } + + CredentialsManagerException.SESSION_EXPIRED -> { + // The upstream identity provider's session_expiry ceiling was reached. + // The stored credentials have already been cleared; prompt the user to + // re-authenticate. + } // ... similarly for other error codes } ``` +### Upstream session expiry + +When an enterprise connection (for example an OIDC or Okta connection) is configured to assert a session lifetime, Auth0 includes a `session_expiry` claim in the ID token. This claim is an absolute ceiling — expressed in **Unix seconds** — on how long the local session may live, independently of the access-token expiry. It usually sits much further out than `expiresAt`, and it cannot be extended by a refresh-token renewal. + +The credentials managers enforce this ceiling automatically: + +- The ceiling is read from the ID token at login and persisted, so it survives refreshes whose ID token does not re-emit the claim. +- `saveCredentials` rejects an already-expired session up front: if the ID token is already past its ceiling at login, the save throws `CredentialsManagerException.SESSION_EXPIRED` and nothing is persisted. +- On every `getCredentials` call, if the ceiling has been reached the stored credentials are cleared and the call fails with `CredentialsManagerException.SESSION_EXPIRED`. The refresh token is **never** used to renew a session past the ceiling. +- A small negative clock-skew leeway (~30 seconds) is applied, so the session is treated as expired slightly *before* the wall-clock ceiling, never after. +- Connections that do not emit the claim are unaffected — there is no ceiling and behavior is unchanged. + +> ⚠️ **The `session_expiry` value must be Unix seconds.** Per [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), the claim is interpreted as seconds since the Unix epoch. A millisecond-magnitude value (e.g. `1700000000000`) resolves to a date ~50,000 years out and would **silently disable** the ceiling, so the SDK treats any implausibly large value (`>= 10_000_000_000`) as "no ceiling". The SDK also **fails open** on any malformed value — a non-numeric, zero, negative, or millisecond value is treated as "no ceiling" and the session proceeds without enforcement. When emitting the claim from an Action, always use seconds (divide a milliseconds timestamp by 1000). + +> ⚠️ **Upgrade note:** For a user whose connection asserts `session_expiry`, a `getCredentials` call that previously succeeded can now fail with `SESSION_EXPIRED` once the ceiling is reached. Make sure your error handling treats `SESSION_EXPIRED` as a prompt to re-authenticate. + +#### Emitting the claim + +The `session_expiry` claim is not emitted by default — it is set on your tenant by a [Post-Login Action](https://auth0.com/docs/customize/actions/flows-and-triggers/login-flow) that adds it to the ID token, for example: + +```javascript +exports.onExecutePostLogin = async (event, api) => { + // session_expiry must be expressed in Unix seconds + const sessionExpiry = Math.floor(Date.now() / 1000) + 8 * 60 * 60; // 8 hours from now + api.idToken.setCustomClaim('session_expiry', sessionExpiry); +}; +``` + +> 📝 A link to the canonical Auth0 `session_expiry` Action guide will be added here once it is published. + +You can read the ceiling for a given credential set from `Credentials.sessionExpiresAt` (a nullable `Long` of Unix seconds, `null` when the connection does not emit the claim): + +```kotlin +val credentials = credentialsManager.awaitCredentials() +val ceiling: Long? = credentials.sessionExpiresAt +``` + ## Passkeys User should have a custom domain configured and passkey grant-type enabled in the Auth0 dashboard to use passkeys. @@ -2588,8 +3296,19 @@ To sign up a user with passkey ```kotlin // Using Coroutines try { + val userData = UserData( + email = "user@example.com", + phoneNumber = "+11234567890", + name = "John Doe", + givenName = "John", + familyName = "Doe", + nickName = "johnny", + picture = "https://example.com/photo.png", + userMetadata = mapOf("signup_source" to "android_app") + ) + val challenge = authenticationApiClient.signupWithPasskey( - "{user-data}", + userData, "{realm}", "{organization-id}" ).await() @@ -2619,7 +3338,19 @@ try { Using Java ```java - authenticationAPIClient.signupWithPasskey("{user-data}", "{realm}","{organization-id}") + UserData userData = new UserData( + "user@example.com", // email + "+11234567890", // phoneNumber + null, // userName + "John Doe", // name + "John", // givenName + "Doe", // familyName + "johnny", // nickName + "https://example.com/photo.png", // picture + Map.of("signup_source", "android_app") // userMetadata + ); + + authenticationAPIClient.signupWithPasskey(userData, "{realm}","{organization-id}") .start(new Callback() { @Override public void onSuccess(PasskeyRegistrationChallenge result) { @@ -2851,232 +3582,6 @@ params.put("screen_hint", "signup"); ``` -## Management API - -The client provides a few methods to interact with the [Users Management API](https://auth0.com/docs/api/management/v2/#!/Users). - -Create a new instance passing the account and an access token with the Management API audience and the right scope: - -```kotlin -val users = UsersAPIClient(account, "api access token") -``` - -
- Using Java - -```java -Auth0 account = Auth0.getInstance("client id", "domain"); -UsersAPIClient users = new UsersAPIClient(account, "api token"); -``` -
- -### Link users - -```kotlin -users - .link("primary user id", "secondary user token") - .start(object: Callback, ManagementException> { - - override fun onFailure(exception: ManagementException) { } - - override fun onSuccess(identities: List) { } - }) -``` - -
- Using coroutines - -```kotlin -try { - val identities = users - .link("primary user id", "secondary user token") - .await() - println(identities) -} catch (e: ManagementException) { - e.printStacktrace() -} -``` -
- -
- Using Java - -```java -users - .link("primary user id", "secondary user token") - .start(new Callback, ManagementException>() { - @Override - public void onSuccess(List payload) { - //Got the updated identities! Accounts linked. - } - - @Override - public void onFailure(@NonNull ManagementException error) { - //Error! - } - }); -``` -
- -### Unlink users - -```kotlin -users - .unlink("primary user id", "secondary user id", "secondary provider") - .start(object: Callback, ManagementException> { - - override fun onFailure(exception: ManagementException) { } - - override fun onSuccess(identities: List) { } - }) -``` - -
- Using coroutines - -```kotlin -try { - val identities = users - .unlink("primary user id", "secondary user id", "secondary provider") - .await() - println(identities) -} catch (e: ManagementException) { - e.printStacktrace() -} -``` -
- -
- Using Java - -```java -users - .unlink("primary user id", "secondary user id", "secondary provider") - .start(new Callback, ManagementException>() { - @Override - public void onSuccess(List payload) { - //Got the updated identities! Accounts linked. - } - - @Override - public void onFailure(@NonNull ManagementException error) { - //Error! - } - }); -``` -
- -### Get User Profile - -```kotlin -users - .getProfile("user id") - .start(object: Callback { - - override fun onFailure(exception: ManagementException) { } - - override fun onSuccess(identities: UserProfile) { } - }) -``` - -
- Using coroutines - -```kotlin -try { - val user = users - .getProfile("user id") - .await() - println(user) -} catch (e: ManagementException) { - e.printStacktrace() -} -``` -
- -
- Using Java - -```java -users - .getProfile("user id") - .start(new Callback() { - @Override - public void onSuccess(@Nullable UserProfile payload) { - //Profile received - } - - @Override - public void onFailure(@NonNull ManagementException error) { - //Error! - } - }); -``` -
- -### Update User Metadata - -```kotlin -val metadata = mapOf( - "name" to listOf("My", "Name", "Is"), - "phoneNumber" to "1234567890" -) - -users - .updateMetadata("user id", metadata) - .start(object: Callback { - - override fun onFailure(exception: ManagementException) { } - - override fun onSuccess(identities: UserProfile) { } - }) -``` - -
- Using coroutines - -```kotlin -val metadata = mapOf( - "name" to listOf("My", "Name", "Is"), - "phoneNumber" to "1234567890" -) - -try { - val user = users - .updateMetadata("user id", metadata) - .await() - println(user) -} catch (e: ManagementException) { - e.printStacktrace() -} -``` -
- -
- Using Java - -```java -Map metadata = new HashMap<>(); -metadata.put("name", Arrays.asList("My", "Name", "Is")); -metadata.put("phoneNumber", "1234567890"); - -users - .updateMetadata("user id", metadata) - .start(new Callback() { - @Override - public void onSuccess(@Nullable UserProfile payload) { - //User Metadata updated - } - - @Override - public void onFailure(@NonNull ManagementException error) { - //Error! - } - }); -``` -
- -> In all the cases, the `user ID` parameter is the unique identifier of the auth0 account instance. i.e. in `google-oauth2|123456789` it would be the part after the '|' pipe: `123456789`. ## Token Validation The ID token received as part of the authentication flow is should be verified following the [OpenID Connect specification](https://openid.net/specs/openid-connect-core-1_0.html). @@ -3201,10 +3706,12 @@ The Auth0 class can be configured with a `NetworkingClient`, which will be used ### Timeout configuration ```kotlin -val netClient = DefaultClient( - connectTimeout = 30, - readTimeout = 30 -) +val netClient = DefaultClient.Builder() + .connectTimeout(30) + .readTimeout(30) + .writeTimeout(30) + .callTimeout(120) + .build() val account = Auth0.getInstance("{YOUR_CLIENT_ID}", "{YOUR_DOMAIN}") account.networkingClient = netClient @@ -3214,7 +3721,12 @@ account.networkingClient = netClient Using Java ```java -DefaultClient netClient = new DefaultClient(30, 30); +DefaultClient netClient = new DefaultClient.Builder() + .connectTimeout(30) + .readTimeout(30) + .writeTimeout(30) + .callTimeout(120) + .build(); Auth0 account = Auth0.getInstance("client id", "domain"); account.setNetworkingClient(netClient); ``` @@ -3223,23 +3735,30 @@ account.setNetworkingClient(netClient); ### Logging configuration ```kotlin -val netClient = DefaultClient( - enableLogging = true -) +val netClient = DefaultClient.Builder() + .enableLogging(true) + .build() val account = Auth0.getInstance("{YOUR_CLIENT_ID}", "{YOUR_DOMAIN}") account.networkingClient = netClient ``` +You can also provide a custom logger to control where logs are written: + +```kotlin +val netClient = DefaultClient.Builder() + .enableLogging(true) + .logger(HttpLoggingInterceptor.Logger { message -> Log.d("Auth0Http", message) }) + .build() +``` +
Using Java ```java -import java.util.HashMap; - -DefaultClient netClient = new DefaultClient( - 10, 10, new HashMap<>() ,true -); +DefaultClient netClient = new DefaultClient.Builder() + .enableLogging(true) + .build(); Auth0 account = Auth0.getInstance("client id", "domain"); account.setNetworkingClient(netClient); ``` @@ -3248,9 +3767,9 @@ account.setNetworkingClient(netClient); ### Set additional headers for all requests ```kotlin -val netClient = DefaultClient( - defaultHeaders = mapOf("{HEADER-NAME}" to "{HEADER-VALUE}") -) +val netClient = DefaultClient.Builder() + .defaultHeaders(mapOf("{HEADER-NAME}" to "{HEADER-VALUE}")) + .build() val account = Auth0.getInstance("{YOUR_CLIENT_ID}", "{YOUR_DOMAIN}") account.networkingClient = netClient @@ -3263,9 +3782,9 @@ account.networkingClient = netClient Map defaultHeaders = new HashMap<>(); defaultHeaders.put("{HEADER-NAME}", "{HEADER-VALUE}"); -DefaultClient netClient = new DefaultClient( - 10,10 , defaultHeaders -); +DefaultClient netClient = new DefaultClient.Builder() + .defaultHeaders(defaultHeaders) + .build(); Auth0 account = Auth0.getInstance("client id", "domain"); account.setNetworkingClient(netClient); ``` diff --git a/README.md b/README.md index 118760528..c0ff289b3 100644 --- a/README.md +++ b/README.md @@ -20,27 +20,27 @@ - [FAQs](https://github.com/auth0/auth0.android/blob/main/FAQ.md) - [Examples](https://github.com/auth0/auth0.android/blob/main/EXAMPLES.md) - [Docs Site](https://javadoc.io/doc/com.auth0.android/auth0/latest/index.html) - +- [Migration Guide](V4_MIGRATION_GUIDE.md) ## Getting Started ### Requirements -Android API version 31 or later and Java 8+. +Android API version 31 or later and Java 17+. > :warning: Applications targeting Android SDK version 30 (`targetSdkVersion = 30`) and below should use version 2.9.0. -Here’s what you need in `build.gradle` to target Java 8 byte code for Android and Kotlin plugins respectively. +Here’s what you need in `build.gradle` to target Java 17 bytecode for Android and Kotlin plugins respectively. ```groovy android { compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } } ``` @@ -52,7 +52,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:3.13.0' + implementation 'com.auth0.android:auth0:4.0.0-beta.1' } ``` @@ -117,11 +117,11 @@ Next, define the Manifest Placeholders for the Auth0 Domain and Scheme which are apply plugin: 'com.android.application' android { - compileSdkVersion 30 + compileSdkVersion 36 defaultConfig { applicationId "com.auth0.samples" - minSdkVersion 21 - targetSdkVersion 30 + minSdkVersion 24 + targetSdkVersion 36 //... //---> Add the next line diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md new file mode 100644 index 000000000..98421defe --- /dev/null +++ b/V4_MIGRATION_GUIDE.md @@ -0,0 +1,491 @@ +# Migration Guide from SDK v3 to v4 + +## Skill for Coding Agents + +If you use coding agents such as Claude Code or Cursor, we highly recommend adding the Auth0.Android migration skill to your repository: `npx skills add auth0/agent-skills --skill auth0-android-major-migration` + +> **Note:** This guide is actively maintained during the v4 development phase. As new changes are merged, this document will be updated to reflect the latest breaking changes and migration steps. + +v4 of the Auth0 Android SDK includes significant build toolchain updates, updated default values for better out-of-the-box behavior, and behavior changes to simplify credential management. This guide documents the changes required when migrating from v3 to v4. + +--- + +## Table of Contents + +- [**Requirements Changes**](#requirements-changes) + + [Minimum SDK Version](#minimum-sdk-version) + + [Java Version](#java-version) + + [Gradle and Android Gradle Plugin](#gradle-and-android-gradle-plugin) + + [Kotlin Version](#kotlin-version) +- [**Breaking Changes**](#breaking-changes) + + [Classes Removed](#classes-removed) + + [Deprecated MFA Methods Removed from AuthenticationAPIClient](#deprecated-mfa-methods-removed-from-authenticationapiclient) + + [DPoP Configuration Moved to Builder](#dpop-configuration-moved-to-builder) + + [DPoPException.UNSUPPORTED_ERROR Removed](#dpopexceptionunsupported_error-removed) + + [SSOCredentials.expiresIn Renamed to expiresAt](#ssocredentialsexpiresin-renamed-to-expiresat) + + [SecureCredentialsManager Auth0 Constructors Removed](#securecredentialsmanager-auth0-constructors-removed) +- [**Default Values Changed**](#default-values-changed) + + [Credentials Manager minTTL](#credentials-manager-minttl) +- [**Behavior Changes**](#behavior-changes) + + [CredentialsManager Now Uses the Global Executor](#credentialsmanager-now-uses-the-global-executor) + + [clearCredentials() Now Clears All Storage](#clearCredentials-now-clears-all-storage) + + [Storage Interface: New removeAll() Method](#storage-interface-new-removeall-method) +- [**New APIs**](#new-apis) + + [clearAll() — Full Credential and Key Cleanup](#clearall--full-credential-and-key-cleanup) +- [**Dependency Changes**](#dependency-changes) + + [Gson 2.8.9 → 2.11.0](#️-gson-289--2110-transitive-dependency) + + [DefaultClient.Builder](#defaultclientbuilder) +- [**New APIs**](#new-apis) + + [Handling Configuration Changes During Authentication](#handling-configuration-changes-during-authentication) + +--- + +## Requirements Changes + +### Minimum SDK Version + +v4 requires **API level 26** (Android 8.0 Oreo) or later (previously API 21 / Android 5.0 Lollipop). + +Update your `build.gradle` if your `minSdk` is below 26: + +```groovy +android { + defaultConfig { + minSdk 26 + } +} +``` + +**Impact:** Apps targeting devices running Android 7.1 (API 25) or lower will need to increase their minimum SDK version, or continue using v3. + +### Java Version + +v4 requires **Java 17** or later (previously Java 8+). + +Update your `build.gradle` to target Java 17: + +```groovy +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} +``` + +### Gradle and Android Gradle Plugin + +v4 requires: + +- **Gradle**: 8.11.1 or later +- **Android Gradle Plugin (AGP)**: 8.10.1 or later + +Update your `gradle/wrapper/gradle-wrapper.properties`: + +```properties +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +``` + +Update your root `build.gradle`: + +```groovy +buildscript { + dependencies { + classpath 'com.android.tools.build:gradle:8.10.1' + } +} +``` + +### Kotlin Version + +v4 uses **Kotlin 2.0.21**. If you're using Kotlin in your project, you may need to update your +Kotlin version to ensure compatibility. + +```groovy +buildscript { + ext.kotlin_version = "2.0.21" +} +``` + +## Breaking Changes + +### Classes Removed + +- The `com.auth0.android.provider.PasskeyAuthProvider` class has been removed. Use the APIs from + the [AuthenticationAPIClient](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt) + class for passkey operations: + - [passkeyChallenge()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L366-L387) - + Request a challenge to initiate passkey login flow + - [signinWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L235-L253) - + Sign in a user using passkeys + - [signupWithPasskey()](auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt#L319-L344) - + Sign up a user and returns a challenge for key generation + +- The Management API support has been removed. This includes the `UsersAPIClient` class, `ManagementException`, and `ManagementCallback`. + + > **Note:** This only impacts you if your app used the Management API client (`UsersAPIClient`). + + **Impact:** Any code that references `UsersAPIClient`, `ManagementException`, or `ManagementCallback` will no longer compile. + + **Migration:** Instead of calling the Management API directly from your mobile app, expose dedicated endpoints in your own backend that perform the required operations, and call those from the app using the access token you already have. + + For example, if you were reading or updating user metadata: + + 1. Create a backend endpoint (e.g. `PATCH /me/metadata`) that accepts the operation your app needs. + 2. Call that endpoint from your app, passing the user's access token as a `Bearer` token in the `Authorization` header. + 3. On your backend, obtain a machine-to-machine token via the Client Credentials flow and use it to call the Management API with the precise scopes required. + +### Deprecated MFA Methods Removed from AuthenticationAPIClient + +The following MFA methods have been removed from `AuthenticationAPIClient`. They were deprecated in v3 in favor of the `MfaApiClient` class APIs. + +- `loginWithOTP(mfaToken, otp)` +- `loginWithOOB(mfaToken, oobCode, bindingCode)` +- `loginWithRecoveryCode(mfaToken, recoveryCode)` +- `multifactorChallenge(mfaToken, challengeType, authenticatorId)` + +Use `AuthenticationAPIClient.mfaClient(mfaToken)` to obtain a `MfaApiClient` instance and handle MFA flows using the new APIs. See the [MFA Flexible Factors Grant](EXAMPLES.md#mfa-flexible-factors-grant) section in `EXAMPLES.md` for usage guidance. + +### DPoP Configuration Moved to Builder + +The `useDPoP(context: Context)` method has been moved from the `WebAuthProvider` object to the login +`Builder` class. This change allows DPoP to be configured per-request instead of globally. + +**v3 (global configuration — no longer supported):** + +```kotlin +// ❌ This no longer works +WebAuthProvider + .useDPoP(context) + .login(account) + .start(context, callback) +``` + +**v4 (builder-based configuration — required):** + +```kotlin +// ✅ Use this instead +WebAuthProvider + .login(account) + .useDPoP(context) + .start(context, callback) +``` + +This change ensures that DPoP configuration is scoped to individual login requests rather than +persisting across the entire application lifecycle. + +### `DPoPException.UNSUPPORTED_ERROR` Removed + +The `DPoPException.UNSUPPORTED_ERROR` constant has been removed. With the minimum SDK raised to API 26, the SDK no longer needs to guard against unsupported Android versions for DPoP, so this error code is no longer applicable. + +**Impact:** If your code references `DPoPException.UNSUPPORTED_ERROR` (e.g., in a `catch` block or error-handling logic), remove that reference. DPoP is supported on all API levels that v4 targets, so this check is no longer needed. + +### `SSOCredentials.expiresIn` Renamed to `expiresAt` + +**Change:** The `expiresIn` property in `SSOCredentials` has been renamed to `expiresAt` and its type changed from `Int` to `Date`. + +In v3, `expiresIn` held the raw number of seconds until the session transfer token expired. In v4, the SDK now automatically converts this value into an absolute expiration `Date` (computed as current time + seconds) during deserialization, consistent with how `Credentials.expiresAt` works. The property has been renamed to `expiresAt` to reflect that it now represents an absolute point in time rather than a duration. + +**v3:** + +```kotlin +val ssoCredentials: SSOCredentials = // ... +val secondsUntilExpiry: Int = ssoCredentials.expiresIn +``` + +**v4:** + +```kotlin +val ssoCredentials: SSOCredentials = // ... +val expirationDate: Date = ssoCredentials.expiresAt +``` + +**Impact:** If your code references `ssoCredentials.expiresIn`, rename it to `ssoCredentials.expiresAt`. The value is now an absolute `Date` instead of a duration in seconds. + +### SecureCredentialsManager Auth0 Constructors Removed + +**Change:** The two `SecureCredentialsManager` constructors that accepted an `Auth0` instance as their first parameter have been removed. Only the `AuthenticationAPIClient`-based constructors remain. + +In v3, `SecureCredentialsManager` offered four public constructors — two that accepted an `Auth0` object (which the manager used internally to create an `AuthenticationAPIClient`) and two that accepted a pre-built `AuthenticationAPIClient` directly. In v4 the `Auth0`-based constructors are gone, leaving two constructors that both require an `AuthenticationAPIClient`. + +**v3 (removed):** + +```kotlin +// ❌ Auth0-based constructor — no longer exists +val manager = SecureCredentialsManager( + auth0, // Auth0 instance + context, + storage +) + +// ❌ Auth0-based biometric constructor — no longer exists +val manager = SecureCredentialsManager( + auth0, // Auth0 instance + context, + storage, + fragmentActivity, + localAuthenticationOptions +) +``` + +**v4 (required):** + +The manager uses the supplied `AuthenticationAPIClient` for all token renewals and DPoP-bound refreshes, so configure that client first and then pass the same instance into `SecureCredentialsManager`. + +```kotlin +// ✅ Create the AuthenticationAPIClient first, then pass it in +val apiClient = AuthenticationAPIClient(auth0) +val manager = SecureCredentialsManager(apiClient, context, storage) + +// ✅ Biometric variant +val apiClient = AuthenticationAPIClient(auth0) +val manager = SecureCredentialsManager( + apiClient, + context, + storage, + fragmentActivity, + localAuthenticationOptions +) +``` + +
+ Using Java + +```java +// ✅ Standard +AuthenticationAPIClient apiClient = new AuthenticationAPIClient(auth0); +SecureCredentialsManager manager = new SecureCredentialsManager(apiClient, context, storage); + +// ✅ Biometric variant +AuthenticationAPIClient apiClient = new AuthenticationAPIClient(auth0); +SecureCredentialsManager manager = new SecureCredentialsManager( + apiClient, context, storage, fragmentActivity, localAuthenticationOptions); +``` +
+ +**Impact:** Any code that constructs `SecureCredentialsManager` with an `Auth0` instance as the first argument will no longer compile. Create an `AuthenticationAPIClient(auth0)` first and pass that instead. The same change applies to Java — there is no Java-specific overload. + +**Reason:** The `Auth0` parameter was redundant — `AuthenticationAPIClient` already holds a reference to the `Auth0` configuration object. Removing it eliminates the duplication, makes DPoP opt-in configuration (via `AuthenticationAPIClient.useDPoP(context)`) a natural part of construction, and reduces the public API surface. + +--- + +## Default Values Changed + +### Credentials Manager `minTTL` + +**Change:** The default `minTtl` value changed from `0` to `60` seconds. + +This change affects the following Credentials Manager methods: + +- `getCredentials(callback)` / `awaitCredentials()` +- `getCredentials(scope, minTtl, callback)` / `awaitCredentials(scope, minTtl)` +- `getCredentials(scope, minTtl, parameters, callback)` / `awaitCredentials(scope, minTtl, parameters)` +- `getCredentials(scope, minTtl, parameters, forceRefresh, callback)` / `awaitCredentials(scope, minTtl, parameters, forceRefresh)` +- `getCredentials(scope, minTtl, parameters, headers, forceRefresh, callback)` / `awaitCredentials(scope, minTtl, parameters, headers, forceRefresh)` +- `hasValidCredentials()` + +**Impact:** Credentials will be renewed if they expire within 60 seconds, instead of only when already expired. + +
+ Migration example + +```kotlin +// v3 - minTtl defaulted to 0, had to be set explicitly +credentialsManager.getCredentials(scope = null, minTtl = 60, callback = callback) + +// v4 - minTtl defaults to 60 seconds +credentialsManager.getCredentials(callback) + +// v4 - use 0 to restore v3 behavior +credentialsManager.getCredentials(scope = null, minTtl = 0, callback = callback) +``` +
+ +**Reason:** A `minTtl` of `0` meant credentials were not renewed until expired, which could result in delivering access tokens that expire immediately after retrieval, causing subsequent API requests to fail. Setting a default value of `60` seconds ensures the access token remains valid for a reasonable period. + +## Behavior Changes + +### `CredentialsManager` Now Uses the Global Executor + +**Change:** `CredentialsManager` no longer creates a per-instance `Executor`. It now uses the same process-wide single-thread executor already used by `SecureCredentialsManager` . + +In v3, each `CredentialsManager` instance created its own `Executors.newSingleThreadExecutor()`. Two `CredentialsManager` instances could therefore run `getCredentials` concurrently, racing to exchange the same refresh token and potentially triggering duplicate `invalid_grant` errors on token rotation. `SecureCredentialsManager` was already on the global executor — this was an inconsistency between the two manager types. + +In v4, `getCredentials` and `getApiCredentials` calls from any manager instance backed by the same `Auth0` object are queued on one global single-thread executor. The first caller renews the token, saves the updated credentials, and returns. Subsequent callers find the already-refreshed credentials in storage and return without making a network request. + +**Impact:** If your app creates multiple `CredentialsManager` instances backed by the same `Auth0` object, their renewal operations are now serialized rather than concurrent. In practice this eliminates duplicate refresh-token exchanges. The only observable downside is that a slow renewal blocks other callers until it completes. + +No code changes are required. This is a runtime-only behavior change. + +--- + +### `clearCredentials()` Now Clears All Storage + +**Change:** `clearCredentials()` now calls `Storage.removeAll()` instead of removing individual credential keys. + +In v3, `clearCredentials()` removed only specific credential keys (access token, refresh token, ID token, etc.) from the underlying `Storage`. + +In v4, `clearCredentials()` calls `Storage.removeAll()`, which clears **all** values in the storage — including any API credentials stored for specific audiences. + +**Impact:** If you need to remove only the primary credentials while preserving other stored data, consider using a separate `Storage` instance for API credentials. + +**Reason:** This simplifies credential cleanup and ensures no stale data remains in storage after logout. It aligns the behavior with the Swift SDK's `clear()` method, which also clears all stored values. + +### `Storage` Interface: New `removeAll()` Method + +**Change:** The `Storage` interface now includes a `removeAll()` method with a default empty implementation. + +**Impact:** Existing custom `Storage` implementations will continue to compile and work without changes. Override `removeAll()` to provide the actual clearing behavior if your custom storage is used with `clearCredentials()`. + +## New APIs + +### `clearAll()` — Full Credential and Key Cleanup + +v4 introduces a new `clearAll()` method on `CredentialsManager` and `SecureCredentialsManager` that performs a complete cleanup of all stored credentials **and** cryptographic key pairs. + +**Usage:** + +```kotlin +// Clear everything on logout — credentials, DPoP keys, and encryption keys +credentialsManager.clearAll() +``` + +**When to use `clearAll()` vs `clearCredentials()`:** + +- Use **`clearCredentials()`** when you only need to remove stored tokens (e.g., forcing a re-login) but want to preserve cryptographic keys for future sessions. +- Use **`clearAll()`** on full logout or account removal, when you want to ensure no credentials or key material remain on the device. + +> **Note:** `clearAll()` catches any errors from DPoP key pair deletion internally, so it will not throw even if the DPoP key pair was never created or has already been removed. + +## Dependency Changes + +### ⚠️ Gson 2.8.9 → 2.11.0 (Transitive Dependency) + +v4 updates the internal Gson dependency from **2.8.9** to **2.11.0**. While the SDK does not expose +Gson types in its public API, Gson is included as a transitive runtime dependency. If your app also +uses Gson, be aware of the following changes introduced in Gson 2.10+: + +- **`TypeToken` with unresolved type variables is rejected at runtime.** Code like + `object : TypeToken>() {}` (where `T` is a generic parameter) will throw + `IllegalArgumentException`. Use Kotlin `reified` type parameters or pass concrete types instead. +- **Strict type coercion is enforced.** Gson no longer silently coerces JSON objects or arrays to + `String`. If your code relies on this behavior, you will see `JsonSyntaxException`. +- **Built-in ProGuard/R8 rules are included.** Gson 2.11.0 ships its own keep rules, so you may be + able to remove custom Gson ProGuard rules from your project. + +If you need to pin Gson to an older version, you can use Gradle's `resolutionStrategy`: + +```groovy +configurations.all { + resolutionStrategy.force 'com.google.code.gson:gson:2.8.9' +} +``` + +Alternatively, you can exclude Gson from the SDK entirely and provide your own version: + +```groovy +implementation('com.auth0.android:auth0:') { + exclude group: 'com.google.code.gson', module: 'gson' +} +implementation 'com.google.code.gson:gson:2.8.9' // your preferred version +``` + +> **Note:** Pinning or excluding is not recommended long-term, as the SDK has been tested and +> validated against Gson 2.11.0. + +### DefaultClient.Builder + +v4 introduces a `DefaultClient.Builder` for configuring the HTTP client. This replaces the +constructor-based approach with a more flexible builder pattern that supports additional options +such as write/call timeouts, custom interceptors, and custom loggers. + +**v3 (constructor-based — deprecated):** + +```kotlin +// ⚠️ Deprecated: still compiles but shows a warning +val client = DefaultClient( + connectTimeout = 30, + readTimeout = 30, + enableLogging = true +) +``` + +**v4 (builder pattern — recommended):** + +```kotlin +val client = DefaultClient.Builder() + .connectTimeout(30) + .readTimeout(30) + .writeTimeout(30) + .callTimeout(120) + .enableLogging(true) + .build() +``` + +The legacy constructor is deprecated but **not removed** — existing code will continue to compile +and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to +migrate to the Builder. + +## New APIs + +### Handling Configuration Changes During Authentication + +v4 fixes a memory leak and lost callback issue when the Activity is destroyed during authentication +(e.g. device rotation, locale change, dark mode toggle). The SDK wraps the callback in a +`LifecycleAwareCallback` that observes the host Activity/Fragment lifecycle. When `onDestroy` fires, +the reference to the callback is immediately nulled out so the destroyed Activity is no longer held +in memory. + +If the authentication result arrives while the Activity is being recreated, it is cached internally. +Call `WebAuthProvider.registerCallbacks()` once in your `onCreate()` — this single call handles both +recovery scenarios and manages the callback lifecycle automatically: + +```kotlin +class LoginActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WebAuthProvider.registerCallbacks( + lifecycleOwner = this, + loginCallback = object : Callback { + override fun onSuccess(result: Credentials) { /* handle credentials */ } + override fun onFailure(error: AuthenticationException) { /* handle error */ } + }, + logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { /* handle logout */ } + override fun onFailure(error: AuthenticationException) { /* handle error */ } + } + ) + } + + fun onLoginClick() { + WebAuthProvider.login(account) + .withScheme("myapp") + .start(this, callback) + } +} +``` + +`registerCallbacks()` covers both scenarios in one call: + +| Scenario | How it's handled | +|----------|-----------------| +| **Configuration change** (rotation, locale, dark mode) | The result is delivered directly to the registered callback once the async token exchange completes. If no callback is registered yet, the result is cached and delivered on the next `onResume` | +| **Process death** (system killed the app while browser was open) | `AuthenticationActivity` restores the OAuth state and processes the redirect. Since all static state (including `callbacks`) was wiped, the result is cached in `pendingLoginResult`. When your Activity is recreated and calls `registerCallbacks()`, the cached result is delivered on the next `onResume` | + +> **Note:** Both `loginCallback` and `logoutCallback` are required — this ensures results from either flow are never lost during configuration changes or process death. + +> **Note:** If you use the `suspend fun await()` API from a ViewModel coroutine scope, the +> Activity is never captured in the callback chain, so you do not need `registerCallbacks()` calls. +> See the sample app for a ViewModel-based example. + +## Getting Help + +If you encounter issues during migration: + +- [GitHub Issues](https://github.com/auth0/Auth0.Android/issues) - Report bugs or ask questions +- [Auth0 Community](https://community.auth0.com/) - Community support \ No newline at end of file diff --git a/auth0/build.gradle b/auth0/build.gradle index e3b5cae26..e4f8510cb 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -34,11 +34,16 @@ version = getVersionFromFile() logger.lifecycle("Using version ${version} for ${name}") android { - compileSdk 35 + namespace 'com.auth0.android.auth0' + compileSdk 36 + + buildFeatures { + buildConfig = true + } defaultConfig { - minSdkVersion 21 - targetSdk 35 + minSdkVersion 26 + targetSdk 36 versionCode 1 versionName project.version @@ -47,12 +52,12 @@ android { manifestPlaceholders = [auth0Domain: '${auth0Domain}', auth0Scheme: '${auth0Scheme}'] - consumerProguardFiles '../proguard/proguard-gson.pro', '../proguard/proguard-okio.pro', '../proguard/proguard-jetpack.pro' + consumerProguardFiles '../proguard/proguard-gson.pro', '../proguard/proguard-okio.pro' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } - lintOptions { - htmlReport true - abortOnError true + lint { + htmlReport = true + abortOnError = true } testOptions { unitTests { @@ -63,11 +68,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() freeCompilerArgs += [ '-Xexplicit-api=strict', // or '-Xexplicit-api=warning' ] @@ -76,46 +81,37 @@ android { ext { okhttpVersion = '4.12.0' - powermockVersion = '2.0.9' - coroutinesVersion = '1.6.2' + coroutinesVersion = '1.10.2' biometricLibraryVersion = '1.1.0' - credentialManagerVersion = "1.3.0" } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'androidx.core:core-ktx:1.6.0' - implementation 'androidx.appcompat:appcompat:1.6.0' - implementation 'androidx.browser:browser:1.4.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.browser:browser:1.10.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" - implementation 'com.google.code.gson:gson:2.8.9' - implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.4.0' + implementation 'com.google.code.gson:gson:2.11.0' + implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0' implementation "androidx.biometric:biometric:$biometricLibraryVersion" testImplementation 'junit:junit:4.13.2' testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0' - testImplementation "org.powermock:powermock-module-junit4:$powermockVersion" - testImplementation "org.powermock:powermock-module-junit4-rule:$powermockVersion" - testImplementation "org.powermock:powermock-api-mockito2:$powermockVersion" - testImplementation 'org.mockito:mockito-core:3.12.4' - // Mockito-Kotlin: See https://github.com/nhaarman/mockito-kotlin/wiki/Parameter-specified-as-non-null-is-null - testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0' + testImplementation 'org.mockito:mockito-core:5.14.0' + testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0' testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion" testImplementation "com.squareup.okhttp3:okhttp-tls:$okhttpVersion" testImplementation 'com.jayway.awaitility:awaitility:1.7.0' - testImplementation 'org.robolectric:robolectric:4.8.1' - testImplementation 'androidx.test.espresso:espresso-intents:3.5.1' + testImplementation 'org.robolectric:robolectric:4.15.1' + testImplementation 'androidx.test.espresso:espresso-intents:3.6.1' testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "androidx.biometric:biometric:$biometricLibraryVersion" - - implementation "androidx.credentials:credentials-play-services-auth:$credentialManagerVersion" - implementation "androidx.credentials:credentials:$credentialManagerVersion" } apply from: rootProject.file('gradle/jacoco.gradle') diff --git a/auth0/src/main/AndroidManifest.xml b/auth0/src/main/AndroidManifest.xml index 96c1233b1..1532d6819 100644 --- a/auth0/src/main/AndroidManifest.xml +++ b/auth0/src/main/AndroidManifest.xml @@ -1,8 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools"> diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index eec6bd1fb..d280a89a3 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -6,6 +6,8 @@ import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.mfa.MfaApiClient +import com.auth0.android.authentication.passwordless.PasswordlessClient +import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.SenderConstraining @@ -25,7 +27,6 @@ import com.auth0.android.request.internal.GsonAdapter.Companion.forMapOf import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.RequestFactory import com.auth0.android.request.internal.ResponseUtils.isNetworkError -import com.auth0.android.result.Challenge import com.auth0.android.result.Credentials import com.auth0.android.result.DatabaseUser import com.auth0.android.result.PasskeyChallenge @@ -37,6 +38,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import java.io.IOException import java.io.Reader import java.security.PublicKey +import java.util.concurrent.Executor /** * API client for Auth0 Authentication API. @@ -55,6 +57,13 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private var dPoP: DPoP? = null + /** + * Returns whether DPoP (Demonstrating Proof of Possession) is enabled on this client. + * DPoP is enabled by calling [useDPoP]. + */ + public val isDPoPEnabled: Boolean + get() = dPoP != null + /** * Creates a new API client instance providing Auth0 account info. * @@ -77,6 +86,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe public val baseURL: String get() = auth0.getDomainUrl() + + internal val executor: Executor + get() = auth0.executor + /** * Enable DPoP for this client. */ @@ -104,10 +117,32 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * ``` * * @param mfaToken The token received in the 'mfa_required' error from a login attempt. - * @return A new [MfaApiClient] instance configured for the transaction. + * @return A new [MfaApiClient] instance configured for the transaction. If this client has + * DPoP enabled via [useDPoP], the returned MFA client inherits that configuration. */ public fun mfaClient(mfaToken: String): MfaApiClient { - return MfaApiClient(this.auth0, mfaToken) + return MfaApiClient(this.auth0, mfaToken, gson, this.dPoP) + } + + /** + * Creates a [PasswordlessClient] for the database-connection passwordless flow. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * ## Usage + * + * ```kotlin + * val passwordless = authClient.passwordlessClient() + * ``` + * + * @return a new [PasswordlessClient] instance bound to this client's Auth0 account. + */ + public fun passwordlessClient(): PasswordlessClient { + return PasswordlessClient(this.auth0, gson, this.dPoP) } /** @@ -174,37 +209,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return loginWithToken(requestParameters) } - /** - * Log in a user using the One Time Password code after they have received the 'mfa_required' error. - * The MFA token tells the server the username or email, password, and realm values sent on the first request. - * - * Requires your client to have the **MFA OTP** Grant Type enabled. See [Client Grant Types](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. - * - * Example usage: - * - *``` - * client.loginWithOTP("{mfa token}", "{one time password}") - * .validateClaims() //mandatory - * .start(object : Callback { - * override fun onFailure(error: AuthenticationException) { } - * override fun onSuccess(result: Credentials) { } - * }) - *``` - * - * @param mfaToken the token received in the previous [.login] response. - * @param otp the one time password code provided by the resource owner, typically obtained from an - * MFA application such as Google Authenticator or Guardian. - * @return a request to configure and start that will yield [Credentials] - */ - public fun loginWithOTP(mfaToken: String, otp: String): AuthenticationRequest { - val parameters = ParameterBuilder.newBuilder() - .setGrantType(ParameterBuilder.GRANT_TYPE_MFA_OTP) - .set(MFA_TOKEN_KEY, mfaToken) - .set(ONE_TIME_PASSWORD_KEY, otp) - .asDictionary() - return loginWithToken(parameters) - } - /** * Sign-in a user using passkeys. @@ -301,17 +305,24 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * * Example usage: * - * * ``` - * client.signupWithPasskey("{userData}","{realm}","{organization}") - * .addParameter("scope","scope") - * .start(object: Callback { - * override fun onSuccess(result: PasskeyRegistration) { } + * val userData = UserData( + * email = "user@example.com", + * name = "John Doe", + * givenName = "John", + * familyName = "Doe", + * nickName = "johnny", + * picture = "https://example.com/photo.png", + * userMetadata = mapOf("signup_source" to "android_app") + * ) + * client.signupWithPasskey(userData, "{realm}", "{organization}") + * .start(object: Callback { + * override fun onSuccess(result: PasskeyRegistrationChallenge) { } * override fun onFailure(error: AuthenticationException) { } * }) * ``` * - * @param userData user information of the client + * @param userData user information for registration. * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing up * @return a request to configure and start that will yield [PasskeyRegistrationChallenge] @@ -321,7 +332,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe realm: String? = null, organization: String? = null ): Request { - val user = gson.toJsonTree(userData) val url = auth0.getDomainUrl().toHttpUrl().newBuilder() .addPathSegment(PASSKEY_PATH) .addPathSegment(REGISTER_PATH) @@ -339,7 +349,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe ) val post = factory.post(url.toString(), passkeyRegistrationChallengeAdapter) .addParameters(params) as BaseRequest - post.addParameter(USER_PROFILE_KEY, user) + post.addParameter(USER_PROFILE_KEY, gson.toJsonTree(userData.toUserProfile())) + userData.userMetadata?.let { post.addParameter(USER_METADATA_KEY, it) } return post } @@ -386,120 +397,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe .addParameters(parameters) } - /** - * Log in a user using an Out Of Band authentication code after they have received the 'mfa_required' error. - * The MFA token tells the server the username or email, password, and realm values sent on the first request. - * - * Requires your client to have the **MFA OOB** Grant Type enabled. See [Client Grant Types](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. - * - * Example usage: - * - *``` - * client.loginWithOOB("{mfa token}", "{out of band code}", "{binding code}") - * .validateClaims() //mandatory - * .start(object : Callback { - * override fun onFailure(error: AuthenticationException) { } - * override fun onSuccess(result: Credentials) { } - * }) - *``` - * - * @param mfaToken the token received in the previous [.login] response. - * @param oobCode the out of band code received in the challenge response. - * @param bindingCode the code used to bind the side channel (used to deliver the challenge) with the main channel you are using to authenticate. - * This is usually an OTP-like code delivered as part of the challenge message. - * @return a request to configure and start that will yield [Credentials] - */ - public fun loginWithOOB( - mfaToken: String, - oobCode: String, - bindingCode: String? = null - ): AuthenticationRequest { - val parameters = ParameterBuilder.newBuilder() - .setGrantType(ParameterBuilder.GRANT_TYPE_MFA_OOB) - .set(MFA_TOKEN_KEY, mfaToken) - .set(OUT_OF_BAND_CODE_KEY, oobCode) - .set(BINDING_CODE_KEY, bindingCode) - .asDictionary() - return loginWithToken(parameters) - } - - /** - * Log in a user using a multi-factor authentication Recovery Code after they have received the 'mfa_required' error. - * The MFA token tells the server the username or email, password, and realm values sent on the first request. - * - * Requires your client to have the **MFA** Grant Type enabled. See [Client Grant Types](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. - * - * Example usage: - * - *``` - * client.loginWithRecoveryCode("{mfa token}", "{recovery code}") - * .validateClaims() //mandatory - * .start(object : Callback { - * override fun onFailure(error: AuthenticationException) { } - * override fun onSuccess(result: Credentials) { } - * }) - *``` - * - * @param mfaToken the token received in the previous [.login] response. - * @param recoveryCode the recovery code provided by the end-user. - * @return a request to configure and start that will yield [Credentials]. It might also include a [recoveryCode] field, - * which your application must display to the end-user to be stored securely for future use. - */ - public fun loginWithRecoveryCode( - mfaToken: String, - recoveryCode: String - ): AuthenticationRequest { - val parameters = ParameterBuilder.newBuilder() - .setGrantType(ParameterBuilder.GRANT_TYPE_MFA_RECOVERY_CODE) - .set(MFA_TOKEN_KEY, mfaToken) - .set(RECOVERY_CODE_KEY, recoveryCode) - .asDictionary() - return loginWithToken(parameters) - } - - /** - * Request a challenge for multi-factor authentication (MFA) based on the challenge types supported by the application and user. - * The challenge type is how the user will get the challenge and prove possession. Supported challenge types include: "otp" and "oob". - * - * Example usage: - * - *``` - * client.multifactorChallenge("{mfa token}", "{challenge type}", "{authenticator id}") - * .start(object : Callback { - * override fun onFailure(error: AuthenticationException) { } - * override fun onSuccess(result: Challenge) { } - * }) - *``` - * - * @param mfaToken the token received in the previous [.login] response. - * @param challengeType A whitespace-separated list of the challenges types accepted by your application. - * Accepted challenge types are oob or otp. Excluding this parameter means that your client application - * accepts all supported challenge types. - * @param authenticatorId The ID of the authenticator to challenge. - * @return a request to configure and start that will yield [Challenge] - */ - public fun multifactorChallenge( - mfaToken: String, - challengeType: String? = null, - authenticatorId: String? = null - ): Request { - val parameters = ParameterBuilder.newBuilder() - .setClientId(clientId) - .set(MFA_TOKEN_KEY, mfaToken) - .set(CHALLENGE_TYPE_KEY, challengeType) - .set(AUTHENTICATOR_ID_KEY, authenticatorId) - .asDictionary() - val url = auth0.getDomainUrl().toHttpUrl().newBuilder() - .addPathSegment(MFA_PATH) - .addPathSegment(CHALLENGE_PATH) - .build() - val challengeAdapter: JsonAdapter = GsonAdapter( - Challenge::class.java, gson - ) - return factory.post(url.toString(), challengeAdapter) - .addParameters(parameters) - } - /** * Log in a user using a token obtained from a Native Social Identity Provider, such as Facebook, using ['\oauth\token' endpoint](https://auth0.com/docs/api/authentication#token-exchange-for-native-social) * The default scope used is 'openid profile email'. @@ -784,17 +681,31 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * }) * ``` * + * For delegation/impersonation scenarios, pass [ActorToken] with actor token details. + * When the server issues tokens with an `act` claim, it will be available via [Credentials.user] actor property. + * + * Note: When `actor_token` is present, Auth0 will not issue a refresh token regardless of + * whether `offline_access` is in the scope. The [Credentials.refreshToken] will be null. + * * @param subjectTokenType the subject token type that is associated with the existing Identity Provider. e.g. 'http://acme.com/legacy-token' * @param subjectToken the subject token, typically obtained through the Identity Provider's SDK * @param organization id of the organization the user belongs to + * @param actorToken optional actor token details for delegation/impersonation flows. * @return a request to configure and start that will yield [Credentials] */ + @JvmOverloads public fun customTokenExchange( subjectTokenType: String, subjectToken: String, - organization: String? = null + organization: String? = null, + actorToken: ActorToken? = null ): AuthenticationRequest { - return tokenExchange(subjectTokenType, subjectToken, organization) + return tokenExchange( + subjectTokenType, + subjectToken, + organization, + actorToken + ) } /** @@ -1013,12 +924,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ##Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * * @param refreshToken A valid refresh token obtained as part of Auth0 authentication * @return a request to fetch a session transfer token @@ -1083,7 +988,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private fun tokenExchange( subjectTokenType: String, subjectToken: String, - organization: String? = null + organization: String? = null, + actorToken: ActorToken? = null ): AuthenticationRequest { val parameters = ParameterBuilder.newAuthenticationBuilder().apply { setGrantType(ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) @@ -1092,6 +998,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe organization?.let { set(ORGANIZATION_KEY, it) } + actorToken?.let { + set(ACTOR_TOKEN_KEY, it.token) + set(ACTOR_TOKEN_TYPE_KEY, it.tokenType) + } }.asDictionary() return loginWithToken(parameters) } @@ -1117,15 +1027,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val OAUTH_CODE_KEY = "code" private const val REDIRECT_URI_KEY = "redirect_uri" private const val TOKEN_KEY = "token" - private const val MFA_TOKEN_KEY = "mfa_token" private const val ONE_TIME_PASSWORD_KEY = "otp" - private const val OUT_OF_BAND_CODE_KEY = "oob_code" - private const val BINDING_CODE_KEY = "binding_code" - private const val CHALLENGE_TYPE_KEY = "challenge_type" - private const val AUTHENTICATOR_ID_KEY = "authenticator_id" - private const val RECOVERY_CODE_KEY = "recovery_code" private const val SUBJECT_TOKEN_KEY = "subject_token" + private const val ACTOR_TOKEN_KEY = "actor_token" private const val SUBJECT_TOKEN_TYPE_KEY = "subject_token_type" + private const val ACTOR_TOKEN_TYPE_KEY = "actor_token_type" private const val ORGANIZATION_KEY = "organization" private const val USER_METADATA_KEY = "user_metadata" private const val AUTH_SESSION_KEY = "auth_session" @@ -1140,7 +1046,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val TOKEN_PATH = "token" private const val USER_INFO_PATH = "userinfo" private const val REVOKE_PATH = "revoke" - private const val MFA_PATH = "mfa" private const val CHALLENGE_PATH = "challenge" private const val PASSKEY_PATH = "passkey" private const val REGISTER_PATH = "register" diff --git a/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt index c6087705f..0a1e6566b 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt @@ -1,10 +1,17 @@ package com.auth0.android.authentication.mfa +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.authentication.ParameterBuilder -import com.auth0.android.authentication.mfa.MfaException.* +import com.auth0.android.authentication.mfa.MfaException.MfaChallengeException +import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException +import com.auth0.android.authentication.mfa.MfaException.MfaListAuthenticatorsException +import com.auth0.android.authentication.mfa.MfaException.MfaVerifyException +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.Request @@ -53,24 +60,43 @@ import java.io.Reader public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( private val auth0: Auth0, private val mfaToken: String, - private val gson: Gson -) { + private val gson: Gson, + private var dPoP: DPoP? = null +) : SenderConstraining { + + /** + * Enable DPoP for this client. When enabled, the MFA verification request to + * `/oauth/token` will carry a DPoP proof, binding the issued tokens to a key pair + * held in the Android KeyStore. + */ + public override fun useDPoP(context: Context): MfaApiClient { + dPoP = DPoP(context) + return this + } // Specialized factories for MFA-specific errors private val listAuthenticatorsFactory: RequestFactory by lazy { - RequestFactory(auth0.networkingClient, createListAuthenticatorsErrorAdapter()) + RequestFactory(auth0.networkingClient, createListAuthenticatorsErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } private val enrollmentFactory: RequestFactory by lazy { - RequestFactory(auth0.networkingClient, createEnrollmentErrorAdapter()) + RequestFactory(auth0.networkingClient, createEnrollmentErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } private val challengeFactory: RequestFactory by lazy { - RequestFactory(auth0.networkingClient, createChallengeErrorAdapter()) + RequestFactory(auth0.networkingClient, createChallengeErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } private val verifyFactory: RequestFactory by lazy { - RequestFactory(auth0.networkingClient, createVerifyErrorAdapter()) + RequestFactory(auth0.networkingClient, createVerifyErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } /** @@ -98,16 +124,19 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA * ## Usage * * ```kotlin - * mfaClient.getAuthenticators(listOf("otp", "oob")) + * mfaClient.getAuthenticators(listOf("otp", "phone")) * .start(object : Callback, MfaListAuthenticatorsException> { * override fun onSuccess(result: List) { - * // Only OTP and OOB authenticators returned + * // Only authenticators whose `type` is "otp" or "phone" are returned * } * override fun onFailure(error: MfaListAuthenticatorsException) { } * }) * ``` * - * @param factorsAllowed Array of factor types to filter the authenticators (e.g., `["otp", "oob", "recovery-code"]`). + * Filtering matches each authenticator's [Authenticator.type] field against the + * provided values using exact (case-sensitive) equality. + * + * @param factorsAllowed Array of factor types to filter the authenticators (e.g., `["otp", "phone", "email", "recovery-code"]`). * Must contain at least one factor type. * @return a request to configure and start that will yield a list of [Authenticator] * @@ -175,7 +204,11 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA */ public fun enroll(type: MfaEnrollmentType): Request { return when (type) { - is MfaEnrollmentType.Phone -> enrollOob(oobChannel = "sms", phoneNumber = type.phoneNumber) + is MfaEnrollmentType.Phone -> enrollOob( + oobChannel = "sms", + phoneNumber = type.phoneNumber + ) + is MfaEnrollmentType.Email -> enrollOob(oobChannel = "email", email = type.email) is MfaEnrollmentType.Otp -> enrollOtpInternal() is MfaEnrollmentType.Push -> enrollOob(oobChannel = "auth0") @@ -228,7 +261,6 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA } - /** * Verifies an MFA challenge using the specified verification type. * @@ -278,11 +310,11 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA * transparently by the SDK. * * **Filtering:** - * Authenticators are filtered by their effective type: - * - OOB authenticators: matched by their channel ("sms" or "email") - * - Other authenticators: matched by their type ("otp", "recovery-code", etc.) + * An authenticator is included when [Authenticator.type] exactly matches one of the + * provided [factorsAllowed] values (case-sensitive equality). This mirrors the + * filtering behavior of the Auth0.swift SDK. * - * @param factorsAllowed List of factor types to include (e.g., ["sms", "email", "otp"]) + * @param factorsAllowed List of factor types to include (e.g., ["phone", "email", "otp"]) * @return A JsonAdapter that produces a filtered list of authenticators */ private fun createFilteringAuthenticatorsAdapter(factorsAllowed: List): JsonAdapter> { @@ -290,65 +322,14 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA return object : JsonAdapter> { override fun fromJson(reader: Reader, metadata: Map): List { val allAuthenticators = baseAdapter.fromJson(reader, metadata) - + return allAuthenticators.filter { authenticator -> - matchesFactorType(authenticator, factorsAllowed) + factorsAllowed.contains(authenticator.type) } } } } - /** - * Checks if an authenticator matches any of the allowed factor types. - * - * The matching logic handles various factor type aliases: - * - "sms" or "phone": matches OOB authenticators with SMS channel - * - "email": matches OOB authenticators with email channel - * - "otp" or "totp": matches time-based one-time password authenticators - * - "oob": matches any out-of-band authenticator regardless of channel - * - "recovery-code": matches recovery code authenticators - * - "push-notification": matches push notification authenticators - * - * @param authenticator The authenticator to check - * @param factorsAllowed List of allowed factor types - * @return true if the authenticator matches any allowed factor type - */ - private fun matchesFactorType(authenticator: Authenticator, factorsAllowed: List): Boolean { - val effectiveType = getEffectiveType(authenticator) - - return factorsAllowed.any { factor -> - val normalizedFactor = factor.lowercase(java.util.Locale.ROOT) - when (normalizedFactor) { - "sms", "phone" -> effectiveType == "sms" || effectiveType == "phone" - "email" -> effectiveType == "email" - "otp", "totp" -> effectiveType == "otp" || effectiveType == "totp" - "oob" -> authenticator.authenticatorType == "oob" || authenticator.type == "oob" - "recovery-code" -> effectiveType == "recovery-code" - "push-notification" -> effectiveType == "push-notification" - else -> effectiveType == normalizedFactor || - authenticator.authenticatorType?.lowercase(java.util.Locale.ROOT) == normalizedFactor || - authenticator.type.lowercase(java.util.Locale.ROOT) == normalizedFactor - } - } - } - - /** - * Resolves the effective type of an authenticator for filtering purposes. - * - * OOB (out-of-band) authenticators use their channel ("sms" or "email") as the - * effective type, since users typically filter by delivery method rather than - * the generic "oob" type. Other authenticators use their authenticatorType directly. - * - * @param authenticator The authenticator to get the type for - * @return The effective type string used for filtering - */ - private fun getEffectiveType(authenticator: Authenticator): String { - return when (authenticator.authenticatorType) { - "oob" -> authenticator.oobChannel ?: "oob" - else -> authenticator.authenticatorType ?: authenticator.type - } - } - /** * Helper function for OOB enrollment (SMS, email, push). */ @@ -370,7 +351,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA .addHeader(HEADER_AUTHORIZATION, "Bearer $mfaToken") .addParameter(AUTHENTICATOR_TYPES_KEY, listOf("oob")) .addParameter(OOB_CHANNELS_KEY, listOf(oobChannel)) - + if (phoneNumber != null) { request.addParameter(PHONE_NUMBER_KEY, phoneNumber) } @@ -411,7 +392,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA .setGrantType(GRANT_TYPE_MFA_OOB) .set(MFA_TOKEN_KEY, mfaToken) .set(OUT_OF_BAND_CODE_KEY, oobCode) - + if (bindingCode != null) { parametersBuilder.set(BINDING_CODE_KEY, bindingCode) } @@ -460,12 +441,11 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA Credentials::class.java, gson ) - return verifyFactory.post(url.toString(), credentialsAdapter) + return verifyFactory.post(url.toString(), credentialsAdapter, dPoP) .addParameters(parameters) } - /** * Creates error adapter for getAuthenticators() operations. */ @@ -605,14 +585,20 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA } override fun fromException(cause: Throwable): MfaVerifyException { - return if (isNetworkError(cause)) { - MfaVerifyException( + return when { + isNetworkError(cause) -> MfaVerifyException( code = "network_error", description = "Failed to execute the network request", cause = cause ) - } else { - MfaVerifyException( + + cause is DPoPException -> MfaVerifyException( + code = "dpop_error", + description = cause.message ?: "Error while attaching DPoP proof", + cause = cause + ) + + else -> MfaVerifyException( code = Auth0Exception.UNKNOWN_ERROR, description = cause.message ?: "Something went wrong", cause = cause @@ -643,6 +629,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA private const val RECOVERY_CODE_KEY = "recovery_code" private const val GRANT_TYPE_MFA_OTP = "http://auth0.com/oauth/grant-type/mfa-otp" private const val GRANT_TYPE_MFA_OOB = "http://auth0.com/oauth/grant-type/mfa-oob" - private const val GRANT_TYPE_MFA_RECOVERY_CODE = "http://auth0.com/oauth/grant-type/mfa-recovery-code" + private const val GRANT_TYPE_MFA_RECOVERY_CODE = + "http://auth0.com/oauth/grant-type/mfa-recovery-code" } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt new file mode 100644 index 000000000..f4abbbb0e --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt @@ -0,0 +1,17 @@ +package com.auth0.android.authentication.passwordless + +/** + * Delivery method for a phone-number OTP challenge. + * + * Maps to the `delivery_method` request parameter of `POST /otp/challenge`. [TEXT] sends the + * one-time code via SMS (the server default); [VOICE] delivers it through a voice call. + * + * @property value the wire value sent to the server. + */ +public enum class DeliveryMethod(public val value: String) { + /** Deliver the one-time code via SMS. */ + TEXT("text"), + + /** Deliver the one-time code via a voice call. */ + VOICE("voice") +} diff --git a/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt b/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt new file mode 100644 index 000000000..117fbcaa7 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt @@ -0,0 +1,302 @@ +package com.auth0.android.authentication.passwordless + +import androidx.annotation.VisibleForTesting +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.android.request.AuthenticationRequest +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.Request +import com.auth0.android.request.RequestOptions +import com.auth0.android.request.RequestValidator +import com.auth0.android.request.internal.BaseAuthenticationRequest +import com.auth0.android.request.internal.GsonAdapter +import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.request.internal.RequestFactory +import com.auth0.android.request.internal.ResponseUtils.isNetworkError +import com.auth0.android.result.Credentials +import com.auth0.android.result.PasswordlessChallenge +import com.google.gson.Gson +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.io.IOException +import java.io.Reader + +/** + * API client for the database-connection passwordless authentication flow. + * + * Obtain an instance from + * [com.auth0.android.authentication.AuthenticationAPIClient.passwordlessClient]. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * @see com.auth0.android.authentication.AuthenticationAPIClient.passwordlessClient + */ +public class PasswordlessClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( + private val auth0: Auth0, + private val gson: Gson, + private val dPoP: DPoP? +) { + + private val requestFactory: RequestFactory by lazy { + RequestFactory(auth0.networkingClient, createErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } + } + + /** + * Creates a new PasswordlessClient instance. + * + * @param auth0 the Auth0 account information. + */ + public constructor(auth0: Auth0) : this(auth0, GsonProvider.gson, null) + + private val clientId: String = auth0.clientId + private val baseURL: String = auth0.getDomainUrl() + + /** + * Issues an OTP challenge to an email address for a database connection. + * + * Sends a one-time code to the given email for a connection that has `email_otp` enabled. + * For privacy, the server **always responds successfully regardless of whether the user + * exists** (user-enumeration prevention). On success an opaque [PasswordlessChallenge.authSession] + * is returned — pass it to [loginWithOTP] together with the code the user receives. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * ## Usage + * + * ```kotlin + * passwordless.challengeWithEmail("user@example.com", "Username-Password-Authentication") + * .start(object : Callback { + * override fun onSuccess(result: PasswordlessChallenge) { + * val challenge = result + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param email the email address to send the one-time code to. + * @param connection the name of the database connection; it must have `email_otp` enabled. + * @param allowSignup whether to allow sign-up if the user does not yet exist. Defaults to `false`. + * @return a request that, when started, yields a [PasswordlessChallenge] containing the `auth_session`. + * @see loginWithOTP + */ + @JvmOverloads + public fun challengeWithEmail( + email: String, + connection: String, + allowSignup: Boolean = false + ): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setConnection(connection) + .set(ALLOW_SIGNUP_KEY, allowSignup.toString()) + .set(EMAIL_KEY, email) + .asDictionary() + return challengeRequest(parameters).addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(email, EMAIL_KEY) + requireNotBlank(connection, CONNECTION_KEY) + } + }) + } + + /** + * Issues an OTP challenge to a phone number for a database connection. + * + * Sends a one-time code to the given phone number for a connection that has `phone_otp` + * enabled, delivered either by SMS or voice call per [deliveryMethod]. For privacy, the server + * **always responds successfully regardless of whether the user exists**. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * ## Usage + * + * ```kotlin + * passwordless.challengeWithPhoneNumber("+15555550123", "Username-Password-Authentication", DeliveryMethod.TEXT) + * .start(object : Callback { + * override fun onSuccess(result: PasswordlessChallenge) { + * val challenge = result + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param phoneNumber the E.164 phone number to send the one-time code to (e.g. `"+15555550123"`). + * @param connection the name of the database connection; it must have `phone_otp` enabled. + * @param deliveryMethod how to deliver the code. Defaults to [DeliveryMethod.TEXT]. + * @param allowSignup whether to allow sign-up if the user does not yet exist. Defaults to `false`. + * @return a request that, when started, yields a [PasswordlessChallenge] containing the `auth_session`. + * @see loginWithOTP + */ + @JvmOverloads + public fun challengeWithPhoneNumber( + phoneNumber: String, + connection: String, + deliveryMethod: DeliveryMethod = DeliveryMethod.TEXT, + allowSignup: Boolean = false + ): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setConnection(connection) + .set(ALLOW_SIGNUP_KEY, allowSignup.toString()) + .set(PHONE_NUMBER_KEY, phoneNumber) + .set(DELIVERY_METHOD_KEY, deliveryMethod.value) + .asDictionary() + return challengeRequest(parameters).addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(phoneNumber, PHONE_NUMBER_KEY) + requireNotBlank(connection, CONNECTION_KEY) + } + }) + } + + /** + * Completes the OTP flow by verifying the one-time code and obtaining credentials. + * + * Exchanges the opaque `auth_session` returned by [challengeWithEmail] or + * [challengeWithPhoneNumber], together with the code the user received, for [Credentials] using + * the passwordless OTP grant on `POST /oauth/token`. When DPoP is enabled on the originating + * [com.auth0.android.authentication.AuthenticationAPIClient], a DPoP proof is attached. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * ## Usage + * + * ```kotlin + * passwordless.loginWithOTP(challenge, "123456") + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param passwordlessChallenge the challenge from a prior challenge (see [PasswordlessChallenge]). + * @param otp the one-time code the user received via email, SMS, or voice call. + * @return a request that, when started, yields [Credentials] on success. + * @see challengeWithEmail + * @see challengeWithPhoneNumber + */ + public fun loginWithOTP( + passwordlessChallenge: PasswordlessChallenge, + otp: String + ): AuthenticationRequest { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(OAUTH_PATH) + .addPathSegment(TOKEN_PATH) + .build() + + val parameters = ParameterBuilder.newAuthenticationBuilder() + .setClientId(clientId) + .setGrantType(ParameterBuilder.GRANT_TYPE_PASSWORDLESS_OTP) + .set(AUTH_SESSION_KEY, passwordlessChallenge.authSession) + .set(ONE_TIME_PASSWORD_KEY, otp) + .asDictionary() + + val credentialsAdapter: JsonAdapter = + GsonAdapter(Credentials::class.java, gson) + + val request = BaseAuthenticationRequest( + requestFactory.post(url.toString(), credentialsAdapter, dPoP), clientId, baseURL + ).apply { + addParameters(parameters) + addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(otp, ONE_TIME_PASSWORD_KEY) + } + }) + } + return request + } + + private fun challengeRequest( + parameters: Map + ): Request { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(OTP_PATH) + .addPathSegment(CHALLENGE_PATH) + .build() + + val challengeAdapter: JsonAdapter = + GsonAdapter(PasswordlessChallenge::class.java, gson) + + return requestFactory.post(url.toString(), challengeAdapter) + .addParameters(parameters) + } + + private fun requireNotBlank(value: String, name: String) { + if (value.isBlank()) { + throw AuthenticationException(INVALID_REQUEST, "$name is required") + } + } + + private fun createErrorAdapter(): ErrorAdapter { + val mapAdapter = GsonAdapter.forMap(gson) + return object : ErrorAdapter { + override fun fromRawResponse( + statusCode: Int, bodyText: String, headers: Map> + ): AuthenticationException = AuthenticationException(bodyText, statusCode) + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, + reader: Reader + ): AuthenticationException { + val values = mapAdapter.fromJson(reader) + return AuthenticationException(values, statusCode) + } + + override fun fromException(cause: Throwable): AuthenticationException { + if (isNetworkError(cause)) { + return AuthenticationException( + "Failed to execute the network request", NetworkErrorException(cause) + ) + } + if (cause is DPoPException) { + return AuthenticationException( + cause.message ?: "Error while attaching DPoP proof", cause + ) + } + return AuthenticationException( + "Something went wrong", Auth0Exception("Something went wrong", cause) + ) + } + } + } + + private companion object { + private const val OTP_PATH = "otp" + private const val CHALLENGE_PATH = "challenge" + private const val OAUTH_PATH = "oauth" + private const val TOKEN_PATH = "token" + private const val EMAIL_KEY = "email" + private const val PHONE_NUMBER_KEY = "phone_number" + private const val DELIVERY_METHOD_KEY = "delivery_method" + private const val ALLOW_SIGNUP_KEY = "allow_signup" + private const val CONNECTION_KEY = "connection" + private const val AUTH_SESSION_KEY = "auth_session" + private const val ONE_TIME_PASSWORD_KEY = "otp" + private const val INVALID_REQUEST = "invalid_request" + } +} diff --git a/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt b/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt new file mode 100644 index 000000000..68fc073b2 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt @@ -0,0 +1,20 @@ +package com.auth0.android.authentication.request + +/** + * Represents the acting party in a token exchange delegation/impersonation flow. + * + * An `ActorToken` bundles the token and its type URI together, ensuring both are always provided as required by + * [RFC 8693](https://tools.ietf.org/html/rfc8693). Auth0 requires both `actor_token` and `actor_token_type` to be + * present when performing delegation. + * + * @param token The token representing the acting party (the entity performing actions on behalf of the subject). + * @param tokenType A URI indicating the type of the actor token (e.g., `urn:ietf:params:oauth:token-type:id_token` + * or a custom URI like `http://corporate-idp/id-token`). + * + * @see [RFC 8693: OAuth 2.0 Token Exchange](https://tools.ietf.org/html/rfc8693#section-2.1) + * @see [Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) + */ +public data class ActorToken( + val token: String, + val tokenType: String +) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index d3ac32d59..c6e3c5404 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -4,6 +4,8 @@ import android.util.Log import androidx.annotation.VisibleForTesting import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.DPoPUtil import com.auth0.android.result.APICredentials import com.auth0.android.result.Credentials import com.auth0.android.result.SSOCredentials @@ -20,6 +22,36 @@ public abstract class BaseCredentialsManager internal constructor( protected val storage: Storage, private val jwtDecoder: JWTDecoder ) { + + internal companion object { + internal const val KEY_DPOP_THUMBPRINT = "com.auth0.dpop_key_thumbprint" + + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + internal const val KEY_TOKEN_TYPE = "com.auth0.token_type" + + /** + * Storage key for the IPSIE `session_expiry` ceiling (Unix seconds), persisted at login so it + * survives a refresh whose ID token does not re-emit the claim. See [isSessionExpired]. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + internal const val KEY_SESSION_EXPIRY = "com.auth0.session_expiry" + + /** + * Negative clock-skew leeway (seconds) applied when checking the `session_expiry` ceiling, so + * the session is treated as expired slightly *before* the wall-clock ceiling, never after. + */ + private const val SESSION_EXPIRY_LEEWAY_SECONDS = 30L + + /** + * Default minimum time to live (in seconds) for the access token. + * When retrieving credentials, if the access token has less than this amount of time + * remaining before expiration, it will be automatically renewed. + * This ensures the access token is valid for at least a short window after retrieval, + * preventing downstream API call failures from nearly-expired tokens. + */ + internal const val DEFAULT_MIN_TTL: Int = 60 + } + private var _clock: Clock = ClockImpl() /** @@ -83,7 +115,7 @@ public abstract class BaseCredentialsManager internal constructor( public abstract fun getApiCredentials( audience: String, scope: String? = null, - minTtl: Int = 0, + minTtl: Int = DEFAULT_MIN_TTL, parameters: Map = emptyMap(), headers: Map = emptyMap(), callback: Callback @@ -139,7 +171,7 @@ public abstract class BaseCredentialsManager internal constructor( public abstract suspend fun awaitApiCredentials( audience: String, scope: String? = null, - minTtl: Int = 0, + minTtl: Int = DEFAULT_MIN_TTL, parameters: Map = emptyMap(), headers: Map = emptyMap() ): APICredentials @@ -148,6 +180,7 @@ public abstract class BaseCredentialsManager internal constructor( public abstract fun clearCredentials() public abstract fun clearApiCredentials(audience: String, scope: String? = null) + public abstract fun clearAll() public abstract fun hasValidCredentials(): Boolean public abstract fun hasValidCredentials(minTtl: Long): Boolean @@ -155,6 +188,92 @@ public abstract class BaseCredentialsManager internal constructor( internal val currentTimeInMillis: Long get() = _clock.getCurrentTimeMillis() + /** + * Stores the DPoP key thumbprint if DPoP was used for this credential set. + * Uses a dual strategy to store the thumbprint: + * - credentials.type == "DPoP" when server confirms DPoP but client lacks useDPoP() + * - isDPoPEnabled catches the case where client used DPoP, server returned token_type: "Bearer" + */ + protected fun saveDPoPThumbprint(credentials: Credentials) { + val dpopUsed = credentials.type.equals("DPoP", ignoreCase = true) + || authenticationClient.isDPoPEnabled + + if (!dpopUsed) { + storage.remove(KEY_DPOP_THUMBPRINT) + return + } + + val thumbprint = try { + if (DPoPUtil.hasKeyPair()) DPoPUtil.getPublicKeyJWK() else null + } catch (e: DPoPException) { + Log.w(this::class.java.simpleName, "Failed to fetch DPoP key thumbprint", e) + null + } + + if (thumbprint != null) { + storage.store(KEY_DPOP_THUMBPRINT, thumbprint) + } else { + storage.remove(KEY_DPOP_THUMBPRINT) + } + } + + /** + * Validates DPoP key/token alignment before attempting a refresh. + * + * Uses two signals to detect DPoP-bound credentials: + * - tokenType == "DPoP" + * - KEY_DPOP_THUMBPRINT exists + * + * @param tokenType the token_type value from storage (or decrypted credentials for migration) + * @return null if validation passes, or a CredentialsManagerException if it fails + */ + protected fun validateDPoPState(tokenType: String?): CredentialsManagerException? { + val storedThumbprint = storage.retrieveString(KEY_DPOP_THUMBPRINT) + val isDPoPBound = (tokenType?.equals("DPoP", ignoreCase = true) == true) + || (storedThumbprint != null) + if (!isDPoPBound) return null + + // Check 1: Does the DPoP key still exist in KeyStore? + val hasKey = try { + DPoPUtil.hasKeyPair() + } catch (e: DPoPException) { + Log.e(this::class.java.simpleName, "Failed to check DPoP key existence", e) + false + } + if (!hasKey) { + Log.w(this::class.java.simpleName, "DPoP key missing from KeyStore. Clearing stale credentials.") + clearCredentials() + return CredentialsManagerException(CredentialsManagerException.Code.DPOP_KEY_MISSING) + } + + // Check 2: Is the AuthenticationAPIClient configured with DPoP? + if (!authenticationClient.isDPoPEnabled) { + return CredentialsManagerException(CredentialsManagerException.Code.DPOP_NOT_CONFIGURED) + } + + // Check 3: Does the current key match the one used when credentials were saved? + val currentThumbprint = try { + DPoPUtil.getPublicKeyJWK() + } catch (e: DPoPException) { + Log.e(this::class.java.simpleName, "Failed to read DPoP key thumbprint", e) + null + } + + if (storedThumbprint != null) { + if (currentThumbprint != storedThumbprint) { + Log.w(this::class.java.simpleName, "DPoP key thumbprint mismatch. The key pair has changed since credentials were saved. Clearing stale credentials.") + clearCredentials() + return CredentialsManagerException(CredentialsManagerException.Code.DPOP_KEY_MISMATCH) + } + } else if (currentThumbprint != null) { + // Migration: existing DPoP user upgraded — no thumbprint stored yet. + // Backfill so future checks can detect key rotation. + storage.store(KEY_DPOP_THUMBPRINT, currentThumbprint) + } + + return null + } + /** * Checks if the stored scope is the same as the requested one. * @@ -206,6 +325,93 @@ public abstract class BaseCredentialsManager internal constructor( return expiresAt <= currentTimeInMillis } + /** + * Reads the IPSIE `session_expiry` ceiling (Unix seconds) from the given ID token, or null when + * the token is absent/unparseable or does not carry the claim. + */ + private fun sessionExpiryFromIdToken(idToken: String?): Long? { + if (idToken.isNullOrBlank()) return null + return runCatching { jwtDecoder.decode(idToken).sessionExpiry }.getOrNull() + } + + /** + * Checks whether the upstream-IdP session ceiling (`session_expiry`) has been reached. + * + * The ceiling is resolved in order: (1) the value pinned at login under [KEY_SESSION_EXPIRY]; + * (2) the live claim in [idToken], as a fallback only when nothing is pinned yet (migration of a + * session saved before this control existed); (3) if neither is present there is no ceiling and + * the session is NOT expired — a missing value must fall through to existing behavior, never be + * treated as already-expired. The pinned value is read first because the ceiling is fixed at the + * initial login: a refresh whose ID token re-emits a *later* `session_expiry` must never raise it. + * + * A small negative clock-skew leeway is applied so the session is treated as expired slightly + * before the wall-clock ceiling, never after. + */ + protected fun isSessionExpired(idToken: String?): Boolean { + // A non-positive value is not a valid Unix timestamp; treat it as "not pinned"/"no ceiling" + // (mirrors the unset/migration guard in [willExpire]) so a 0/negative stored sentinel falls + // through to the live claim rather than fail-open as "no ceiling". + val sessionExpiry = storage.retrieveLong(KEY_SESSION_EXPIRY)?.takeIf { it > 0 } + ?: sessionExpiryFromIdToken(idToken)?.takeIf { it > 0 } + ?: return false + val nowSeconds = currentTimeInMillis / 1000 + return nowSeconds + SESSION_EXPIRY_LEEWAY_SECONDS >= sessionExpiry + } + + /** + * Stamps the pinned `session_expiry` ceiling (the value persisted at login under + * [KEY_SESSION_EXPIRY]) onto [credentials] so its public `sessionExpiresAt` reflects the value + * the SDK actually enforces, rather than re-decoding the live ID token — which would diverge + * after a refresh whose token omits or re-emits the claim. No-op when nothing is pinned, so the + * getter falls back to the token claim. Returns the same instance for call-site convenience. + */ + protected fun stampPinnedSessionExpiry(credentials: Credentials): Credentials { + storage.retrieveLong(KEY_SESSION_EXPIRY)?.takeIf { it > 0 }?.let { + credentials.pinnedSessionExpiresAt = it + } + return credentials + } + + /** + * Pins the `session_expiry` ceiling from the initial login and preserves it across refreshes. + * + * The ceiling is read once and stamped onto the session at login: it is stored only when no value + * is already persisted. A `session_expiry` re-emitted on a later (refresh) grant is deliberately + * ignored, so the bound stays pinned to the initial-login value and a refresh can never extend the + * session past it. [clearCredentials] removes the stored value on logout, so the next login re-pins + * a fresh ceiling. Call from `saveCredentials`. + */ + protected fun persistSessionExpiry(idToken: String?) { + val incoming = sessionExpiryFromIdToken(idToken) ?: return + // A positive value is already pinned from the initial login -> keep it; ignore the claim + // re-emitted on this (refresh) grant. A null/non-positive stored value means nothing is pinned + // yet (mirrors the unset/migration guard in [isSessionExpired]), so stamp the ceiling now. + val pinned = storage.retrieveLong(KEY_SESSION_EXPIRY) + if (pinned != null && pinned > 0) return + storage.store(KEY_SESSION_EXPIRY, incoming) + } + + /** + * Validates, at session-creation time, that the given ID token is not already past its + * `session_expiry` ceiling. Throws [CredentialsManagerException] with code `SESSION_EXPIRED` + * when it is, so an already-expired session is never persisted. No-op when the token is absent + * or does not carry both `session_expiry` and `iat`. + * + * The same [SESSION_EXPIRY_LEEWAY_SECONDS] leeway used by [isSessionExpired] is applied here so + * the two checks agree: a ceiling within the leeway of `iat` is rejected up front rather than + * being persisted only to be treated as expired on the very next read. + */ + @Throws(CredentialsManagerException::class) + protected fun validateSessionExpiryAtCreation(idToken: String?) { + if (idToken.isNullOrBlank()) return + val jwt = runCatching { jwtDecoder.decode(idToken) }.getOrNull() ?: return + val sessionExpiry = jwt.sessionExpiry ?: return + val issuedAtSeconds = jwt.issuedAt?.time?.div(1000) ?: return + if (sessionExpiry <= issuedAtSeconds + SESSION_EXPIRY_LEEWAY_SECONDS) { + throw CredentialsManagerException.SESSION_EXPIRED + } + } + /** * Returns the key for storing the APICredentials in storage. Uses a combination of audience and scope. * @@ -219,4 +425,21 @@ public abstract class BaseCredentialsManager internal constructor( return "$audience::${sortedScope}" } + + internal inline fun runCatchingOnExecutor( + callback: Callback, + block: () -> Unit + ) { + try { + block() + } catch (t: Throwable) { + if (t is VirtualMachineError || t is ThreadDeath || t is LinkageError) { + throw t + } + Log.e("BaseCredentialsManager", "Unexpected error in executor block", t) + callback.onFailure( + CredentialsManagerException(CredentialsManagerException.Code.UNKNOWN_ERROR, t) + ) + } + } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt index 31f8e62f4..889a7052e 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -6,6 +6,8 @@ import androidx.annotation.VisibleForTesting import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt import com.auth0.android.result.APICredentials @@ -18,7 +20,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine import java.util.Date import java.util.Locale import java.util.concurrent.Executor -import java.util.concurrent.Executors import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -44,7 +45,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting authenticationClient, storage, JWTDecoder(), - Executors.newSingleThreadExecutor() + authenticationClient.executor ) public override val userProfile: UserProfile? @@ -63,11 +64,17 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * Stores the given credentials in the storage. Must have an access_token or id_token and a expires_in value. * * @param credentials the credentials to save in the storage. + * @throws CredentialsManagerException with code `SESSION_EXPIRED` if the credentials carry an + * IPSIE `session_expiry` claim that is already past its ceiling at creation time, or with code + * `INVALID_CREDENTIALS` if neither an access_token nor an id_token is present. */ + @Throws(CredentialsManagerException::class) override fun saveCredentials(credentials: Credentials) { if (TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken)) { throw CredentialsManagerException.INVALID_CREDENTIALS } + // IPSIE session_expiry: reject a session already past its ceiling at creation time. + validateSessionExpiryAtCreation(credentials.idToken) storage.store(KEY_ACCESS_TOKEN, credentials.accessToken) storage.store(KEY_REFRESH_TOKEN, credentials.refreshToken) storage.store(KEY_ID_TOKEN, credentials.idToken) @@ -75,6 +82,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting storage.store(KEY_EXPIRES_AT, credentials.expiresAt.time) storage.store(KEY_SCOPE, credentials.scope) storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time) + // Preserve the session_expiry ceiling across refreshes: only ever written, never cleared, + // so a refresh whose ID token omits the claim does not silently remove the limit. + persistSessionExpiry(credentials.idToken) + saveDPoPThumbprint(credentials) } /** @@ -102,11 +113,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -123,11 +129,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -137,42 +138,46 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting callback: Callback ) { serialExecutor.execute { - val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) - if (refreshToken.isNullOrEmpty()) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - - val request = authenticationClient.ssoExchange(refreshToken) - try { - if (parameters.isNotEmpty()) { - request.addParameters(parameters) + runCatchingOnExecutor(callback) { + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before exchanging the + // refresh token, so the SSO exchange is never used to outlive the session. + if (isSessionExpired(storage.retrieveString(KEY_ID_TOKEN))) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute } - val sessionTransferCredentials = request.execute() - saveSsoCredentials(sessionTransferCredentials) - callback.onSuccess(sessionTransferCredentials) - } catch (error: AuthenticationException) { - val exception = when { - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.SSO_EXCHANGE_FAILED + val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) + if (refreshToken.isNullOrEmpty()) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute } - callback.onFailure( - CredentialsManagerException( - exception, - error - ) - ) - } catch (exception: RuntimeException) { - Log.e( - TAG, - "Caught unexpected exceptions while fetching sso token ${exception.stackTraceToString()}" - ) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.UNKNOWN_ERROR, - exception + + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + + val request = authenticationClient.ssoExchange(refreshToken) + try { + if (parameters.isNotEmpty()) { + request.addParameters(parameters) + } + val sessionTransferCredentials = request.execute() + saveSsoCredentials(sessionTransferCredentials) + callback.onSuccess(sessionTransferCredentials) + } catch (error: AuthenticationException) { + val exception = when { + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.SSO_EXCHANGE_FAILED + } + callback.onFailure( + CredentialsManagerException( + exception, + error + ) ) - ) + } } } } @@ -185,11 +190,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -208,11 +208,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -244,7 +239,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting @JvmSynthetic @Throws(CredentialsManagerException::class) override suspend fun awaitCredentials(): Credentials { - return awaitCredentials(null, 0) + return awaitCredentials(null, DEFAULT_MIN_TTL) } /** @@ -390,7 +385,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * @param callback the callback that will receive a valid [Credentials] or the [CredentialsManagerException]. */ override fun getCredentials(callback: Callback) { - getCredentials(null, 0, callback) + getCredentials(null, DEFAULT_MIN_TTL, callback) } /** @@ -471,115 +466,118 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting callback: Callback ) { serialExecutor.execute { - val accessToken = storage.retrieveString(KEY_ACCESS_TOKEN) - val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) - val idToken = storage.retrieveString(KEY_ID_TOKEN) - val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) - val expiresAt = storage.retrieveLong(KEY_EXPIRES_AT) - val storedScope = storage.retrieveString(KEY_SCOPE) - val hasEmptyCredentials = - TextUtils.isEmpty(accessToken) && TextUtils.isEmpty(idToken) || expiresAt == null - if (hasEmptyCredentials) { - callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) - return@execute - } - val willAccessTokenExpire = willExpire(expiresAt!!, minTtl.toLong()) - val scopeChanged = hasScopeChanged(storedScope, scope) - if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { - callback.onSuccess( - recreateCredentials( - idToken.orEmpty(), - accessToken.orEmpty(), - tokenType.orEmpty(), - refreshToken, - Date(expiresAt), - storedScope + runCatchingOnExecutor(callback) { + val accessToken = storage.retrieveString(KEY_ACCESS_TOKEN) + val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) + val idToken = storage.retrieveString(KEY_ID_TOKEN) + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) + val expiresAt = storage.retrieveLong(KEY_EXPIRES_AT) + val storedScope = storage.retrieveString(KEY_SCOPE) + val hasEmptyCredentials = + TextUtils.isEmpty(accessToken) && TextUtils.isEmpty(idToken) || expiresAt == null + if (hasEmptyCredentials) { + callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) + return@execute + } + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving any + // cached token or attempting a refresh. Past the ceiling, clear and surface the error + // so the refresh-token grant is never used to outlive the session. + if (isSessionExpired(idToken)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } + val willAccessTokenExpire = willExpire(expiresAt!!, minTtl.toLong()) + val scopeChanged = hasScopeChanged(storedScope, scope) + if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { + callback.onSuccess( + stampPinnedSessionExpiry( + recreateCredentials( + idToken.orEmpty(), + accessToken.orEmpty(), + tokenType.orEmpty(), + refreshToken, + Date(expiresAt), + storedScope + ) + ) ) - ) - return@execute - } - if (refreshToken == null) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - val request = authenticationClient.renewAuth(refreshToken) - request.addParameters(parameters) - if (scope != null) { - request.addParameter("scope", scope) - } + return@execute + } + if (refreshToken == null) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute + } + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + val request = authenticationClient.renewAuth(refreshToken) + request.addParameters(parameters) + if (scope != null) { + request.addParameter("scope", scope) + } - for (header in headers) { - request.addHeader(header.key, header.value) - } + for (header in headers) { + request.addHeader(header.key, header.value) + } - try { - val fresh = request.execute() - val expiresAt = fresh.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - if (willAccessTokenExpire) { - val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 - val wrongTtlException = CredentialsManagerException( - CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( - Locale.getDefault(), - "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", - tokenLifetime, - minTtl + try { + val fresh = request.execute() + val expiresAt = fresh.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + if (willAccessTokenExpire) { + val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 + val wrongTtlException = CredentialsManagerException( + CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( + Locale.getDefault(), + "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + tokenLifetime, + minTtl + ) ) + callback.onFailure(wrongTtlException) + return@execute + } + + // non-empty refresh token for refresh token rotation scenarios + val updatedRefreshToken = + if (TextUtils.isEmpty(fresh.refreshToken)) refreshToken else fresh.refreshToken + val credentials = Credentials( + fresh.idToken, + fresh.accessToken, + fresh.type, + updatedRefreshToken, + fresh.expiresAt, + fresh.scope ) - callback.onFailure(wrongTtlException) - return@execute - } + saveCredentials(credentials) + callback.onSuccess(stampPinnedSessionExpiry(credentials)) + } catch (error: AuthenticationException) { + if (error.isMultifactorRequired) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.MFA_REQUIRED, + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", + error, + error.mfaRequiredErrorPayload + ) + ) + return@execute + } + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - // non-empty refresh token for refresh token rotation scenarios - val updatedRefreshToken = - if (TextUtils.isEmpty(fresh.refreshToken)) refreshToken else fresh.refreshToken - val credentials = Credentials( - fresh.idToken, - fresh.accessToken, - fresh.type, - updatedRefreshToken, - fresh.expiresAt, - fresh.scope - ) - saveCredentials(credentials) - callback.onSuccess(credentials) - } catch (error: AuthenticationException) { - if (error.isMultifactorRequired) { + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } callback.onFailure( CredentialsManagerException( - CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", - error, - error.mfaRequiredErrorPayload + exception, error ) ) - return@execute } - val exception = when { - error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.API_ERROR - } - callback.onFailure( - CredentialsManagerException( - exception, error - ) - ) - } catch (exception: RuntimeException) { - /** - * Catching any unexpected runtime errors in the token renewal flow - */ - Log.e( - TAG, - "Caught unexpected exceptions for token renewal ${exception.stackTraceToString()}" - ) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.UNKNOWN_ERROR, - exception - ) - ) } } } @@ -609,88 +607,106 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting ) { serialExecutor.execute { - //Check if existing api credentials are present and valid - val key = getAPICredentialsKey(audience, scope) - val apiCredentialsJson = storage.retrieveString(key) - apiCredentialsJson?.let { - val apiCredentials = gson.fromJson(it, APICredentials::class.java) - val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong()) - - val scopeChanged = hasScopeChanged( - apiCredentials.scope, - scope, - ignoreOpenid = scope?.contains("openid") == false - ) - - val hasExpired = hasExpired(apiCredentials.expiresAt.time) - - if (!hasExpired && !willTokenExpire && !scopeChanged) { - callback.onSuccess(apiCredentials) + runCatchingOnExecutor(callback) { + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving cached + // API credentials or exchanging the refresh token, so the session is never extended past it. + if (isSessionExpired(storage.retrieveString(KEY_ID_TOKEN))) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) return@execute } - } - //Check if refresh token exists or not - val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) - if (refreshToken == null) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } + //Check if existing api credentials are present and valid + val key = getAPICredentialsKey(audience, scope) + val apiCredentialsJson = storage.retrieveString(key) + var apiCredentialType: String? = null + apiCredentialsJson?.let { + val apiCredentials = gson.fromJson(it, APICredentials::class.java) + apiCredentialType = apiCredentials.type + val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong()) + + val scopeChanged = hasScopeChanged( + apiCredentials.scope, + scope, + ignoreOpenid = scope?.contains("openid") == false + ) - val request = authenticationClient.renewAuth(refreshToken, audience, scope) - request.addParameters(parameters) + val hasExpired = hasExpired(apiCredentials.expiresAt.time) - for (header in headers) { - request.addHeader(header.key, header.value) - } + if (!hasExpired && !willTokenExpire && !scopeChanged) { + callback.onSuccess(apiCredentials) + return@execute + } + } + //Check if refresh token exists or not + val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) + if (refreshToken == null) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute + } - try { - val newCredentials = request.execute() - val expiresAt = newCredentials.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - if (willAccessTokenExpire) { - val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 - val wrongTtlException = CredentialsManagerException( - CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( - Locale.getDefault(), - "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", - tokenLifetime, - minTtl - ) - ) - callback.onFailure(wrongTtlException) + val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) return@execute } - // non-empty refresh token for refresh token rotation scenarios - val updatedRefreshToken = - if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken - val newApiCredentials = newCredentials.toAPICredentials() - storage.store(KEY_REFRESH_TOKEN, updatedRefreshToken) - storage.store(KEY_ID_TOKEN, newCredentials.idToken) - saveApiCredentials(newApiCredentials, audience, scope) - callback.onSuccess(newApiCredentials) - } catch (error: AuthenticationException) { - if (error.isMultifactorRequired) { + val request = authenticationClient.renewAuth(refreshToken, audience, scope) + request.addParameters(parameters) + + for (header in headers) { + request.addHeader(header.key, header.value) + } + + try { + val newCredentials = request.execute() + val expiresAt = newCredentials.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + if (willAccessTokenExpire) { + val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 + val wrongTtlException = CredentialsManagerException( + CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( + Locale.getDefault(), + "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + tokenLifetime, + minTtl + ) + ) + callback.onFailure(wrongTtlException) + return@execute + } + + // non-empty refresh token for refresh token rotation scenarios + val updatedRefreshToken = + if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken + val newApiCredentials = newCredentials.toAPICredentials() + storage.store(KEY_REFRESH_TOKEN, updatedRefreshToken) + storage.store(KEY_ID_TOKEN, newCredentials.idToken) + saveApiCredentials(newApiCredentials, audience, scope) + callback.onSuccess(newApiCredentials) + } catch (error: AuthenticationException) { + if (error.isMultifactorRequired) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.MFA_REQUIRED, + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", + error, + error.mfaRequiredErrorPayload + ) + ) + return@execute + } + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } callback.onFailure( CredentialsManagerException( - CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", - error, - error.mfaRequiredErrorPayload + exception, error ) ) - return@execute - } - val exception = when { - error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.API_ERROR } - callback.onFailure( - CredentialsManagerException( - exception, error - ) - ) } } @@ -702,7 +718,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * @return whether there are valid credentials stored on this manager. */ override fun hasValidCredentials(): Boolean { - return hasValidCredentials(0) + return hasValidCredentials(DEFAULT_MIN_TTL.toLong()) } /** @@ -718,22 +734,35 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting val expiresAt = storage.retrieveLong(KEY_EXPIRES_AT) val emptyCredentials = TextUtils.isEmpty(accessToken) && TextUtils.isEmpty(idToken) || expiresAt == null - return !(emptyCredentials || willExpire( - expiresAt!!, minTtl - ) && refreshToken == null) + if (emptyCredentials) { + return false + } + // IPSIE session_expiry: once the upstream-IdP ceiling passes, no valid credentials remain and + // a refresh cannot extend the session past it, so report no valid credentials. + if (isSessionExpired(idToken)) { + return false + } + return !(willExpire(expiresAt!!, minTtl) && refreshToken == null) } /** * Removes the credentials from the storage if present. */ override fun clearCredentials() { - storage.remove(KEY_ACCESS_TOKEN) - storage.remove(KEY_REFRESH_TOKEN) - storage.remove(KEY_ID_TOKEN) - storage.remove(KEY_TOKEN_TYPE) - storage.remove(KEY_EXPIRES_AT) - storage.remove(KEY_SCOPE) - storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT) + storage.removeAll() + } + + /** + * Removes all credentials, API credentials, and cryptographic key pairs. + * This calls [Storage.removeAll] to clear all stored data + */ + override fun clearAll() { + storage.removeAll() + try { + DPoP.clearKeyPair() + } catch (e: DPoPException) { + Log.e(TAG, "Failed to clear DPoP key pair ${e.stackTraceToString()}") + } } /** @@ -781,7 +810,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting private const val KEY_ACCESS_TOKEN = "com.auth0.access_token" private const val KEY_REFRESH_TOKEN = "com.auth0.refresh_token" private const val KEY_ID_TOKEN = "com.auth0.id_token" - private const val KEY_TOKEN_TYPE = "com.auth0.token_type" private const val KEY_EXPIRES_AT = "com.auth0.expires_at" private const val KEY_SCOPE = "com.auth0.scope" diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt index 9796dbe64..681140489 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt @@ -48,6 +48,10 @@ public class CredentialsManagerException : API_ERROR, SSO_EXCHANGE_FAILED, MFA_REQUIRED, + DPOP_KEY_MISSING, + DPOP_KEY_MISMATCH, + DPOP_NOT_CONFIGURED, + SESSION_EXPIRED, UNKNOWN_ERROR } @@ -159,6 +163,16 @@ public class CredentialsManagerException : public val MFA_REQUIRED: CredentialsManagerException = CredentialsManagerException(Code.MFA_REQUIRED) + public val DPOP_KEY_MISSING: CredentialsManagerException = + CredentialsManagerException(Code.DPOP_KEY_MISSING) + public val DPOP_KEY_MISMATCH: CredentialsManagerException = + CredentialsManagerException(Code.DPOP_KEY_MISMATCH) + public val DPOP_NOT_CONFIGURED: CredentialsManagerException = + CredentialsManagerException(Code.DPOP_NOT_CONFIGURED) + + public val SESSION_EXPIRED: CredentialsManagerException = + CredentialsManagerException(Code.SESSION_EXPIRED) + public val UNKNOWN_ERROR: CredentialsManagerException = CredentialsManagerException(Code.UNKNOWN_ERROR) @@ -207,6 +221,10 @@ public class CredentialsManagerException : Code.API_ERROR -> "An error occurred while processing the request." Code.SSO_EXCHANGE_FAILED ->"The exchange of the refresh token for SSO credentials failed." Code.MFA_REQUIRED -> "Multi-factor authentication is required to complete the credential renewal." + Code.DPOP_KEY_MISSING -> "The stored credentials are DPoP-bound but the DPoP key pair is no longer available in the Android KeyStore. Re-authentication is required." + Code.DPOP_KEY_MISMATCH -> "The stored credentials are DPoP-bound but the current DPoP key pair does not match the one used when credentials were saved. Re-authentication is required." + Code.DPOP_NOT_CONFIGURED -> "The stored credentials are DPoP-bound but the AuthenticationAPIClient used by this credentials manager was not configured with useDPoP(context). Call AuthenticationAPIClient(auth0).useDPoP(context) and pass the configured client to the credentials manager." + Code.SESSION_EXPIRED -> "The session has reached the session_expiry ceiling set by the identity provider and is no longer valid. The user must re-authenticate." Code.UNKNOWN_ERROR -> "An unknown error has occurred while fetching the token. Please check the error cause for more details." } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java b/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java index e0b175e88..5e42756e1 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java @@ -1,10 +1,7 @@ package com.auth0.android.authentication.storage; -import android.app.KeyguardManager; import android.content.Context; -import android.content.Intent; import android.os.Build; -import android.security.KeyPairGeneratorSpec; import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; import android.text.TextUtils; @@ -49,7 +46,7 @@ /** * Created by lbalmaceda on 8/24/17. - * Class to handle encryption/decryption cryptographic operations using AES and RSA algorithms in devices with API 19 or higher. + * Class to handle encryption/decryption cryptographic operations using AES and RSA algorithms in devices with API 26 or higher. */ @SuppressWarnings("WeakerAccess") class CryptoUtil { @@ -180,43 +177,18 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe Calendar start = Calendar.getInstance(); Calendar end = Calendar.getInstance(); end.add(Calendar.YEAR, 25); - AlgorithmParameterSpec spec; X500Principal principal = new X500Principal("CN=Auth0.Android,O=Auth0"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - spec = new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT) - .setCertificateSubject(principal) - .setCertificateSerialNumber(BigInteger.ONE) - .setCertificateNotBefore(start.getTime()) - .setCertificateNotAfter(end.getTime()) - .setKeySize(RSA_KEY_SIZE) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) - .setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256) - .setBlockModes(KeyProperties.BLOCK_MODE_ECB) - .build(); - } else { - //Following code is for API 18-22 - //Generate new RSA KeyPair and save it on the KeyStore - KeyPairGeneratorSpec.Builder specBuilder = new KeyPairGeneratorSpec.Builder(context) - .setAlias(KEY_ALIAS) - .setSubject(principal) - .setKeySize(RSA_KEY_SIZE) - .setSerialNumber(BigInteger.ONE) - .setStartDate(start.getTime()) - .setEndDate(end.getTime()); - - KeyguardManager kManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { - //The next call can return null when the LockScreen is not configured - Intent authIntent = kManager.createConfirmDeviceCredentialIntent(null, null); - boolean keyguardEnabled = kManager.isKeyguardSecure() && authIntent != null; - if (keyguardEnabled) { - //If a ScreenLock is setup, protect this key pair. - specBuilder.setEncryptionRequired(); - } - } - spec = specBuilder.build(); - } + AlgorithmParameterSpec spec = new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT) + .setCertificateSubject(principal) + .setCertificateSerialNumber(BigInteger.ONE) + .setCertificateNotBefore(start.getTime()) + .setCertificateNotAfter(end.getTime()) + .setKeySize(RSA_KEY_SIZE) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256) + .setBlockModes(KeyProperties.BLOCK_MODE_ECB) + .build(); KeyPairGenerator generator = KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE); generator.initialize(spec); @@ -327,6 +299,14 @@ private void deleteAESKeys() { storage.remove(OLD_KEY_IV_ALIAS); } + /** + * Removes all cryptographic keys (both RSA and AES) used by this instance. + */ + public void deleteAllKeys() { + deleteRSAKeys(); + deleteAESKeys(); + } + /** * Decrypts the given input using a generated RSA Private Key. * Used to decrypt the AES key for later usage. @@ -343,10 +323,9 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SPEC); return cipher.doFinal(encryptedInput); - } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) { + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | InvalidAlgorithmParameterException e) { /* - * This exceptions are safe to be ignored: - * * - NoSuchPaddingException: * Thrown if PKCS1Padding is not available. Was introduced in API 1. * - NoSuchAlgorithmException: @@ -361,6 +340,24 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry */ Log.e(TAG, "The device can't decrypt input using a RSA Key.", e); throw new IncompatibleDeviceException(e); + } catch (ProviderException e) { + /* + * - ProviderException: + * Thrown on Android 12+ (API 31+, Keystore2) when the RSA key's padding + * restriction does not match the cipher transformation. For example, an RSA + * key generated with ENCRYPTION_PADDING_RSA_PKCS1 will trigger this when + * initialised with an OAEPWithSHA-1AndMGF1Padding cipher. On API 23-30 the + * same condition surfaces as InvalidKeyException. + * + * This is NOT a device-level incompatibility -- the key can be deleted and + * regenerated with the correct padding. Wrapping as CryptoException (rather + * than IncompatibleDeviceException) ensures the caller falls through to key + * cleanup and regeneration instead of permanently blocking the user. + */ + Log.e(TAG, "RSA key padding mismatch detected (Android 12+ Keystore2).", e); + deleteAESKeys(); + throw new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", e); } catch (IllegalArgumentException | IllegalBlockSizeException | BadPaddingException e) { /* * Any of this exceptions mean the encrypted input is somehow corrupted and cannot be recovered. @@ -394,10 +391,9 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, certificate.getPublicKey(), OAEP_SPEC); return cipher.doFinal(decryptedInput); - } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) { + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | InvalidAlgorithmParameterException e) { /* - * This exceptions are safe to be ignored: - * * - NoSuchPaddingException: * Thrown if PKCS1Padding is not available. Was introduced in API 1. * - NoSuchAlgorithmException: @@ -412,6 +408,24 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry */ Log.e(TAG, "The device can't encrypt input using a RSA Key.", e); throw new IncompatibleDeviceException(e); + } catch (ProviderException e) { + /* + * - ProviderException: + * Thrown on Android 12+ (API 31+, Keystore2) when the RSA key's padding + * restriction does not match the cipher transformation. For example, an RSA + * key generated with ENCRYPTION_PADDING_RSA_PKCS1 will trigger this when + * initialised with an OAEPWithSHA-1AndMGF1Padding cipher. On API 23-30 the + * same condition surfaces as InvalidKeyException. + * + * This is NOT a device-level incompatibility -- the key can be deleted and + * regenerated with the correct padding. Wrapping as CryptoException (rather + * than IncompatibleDeviceException) ensures the caller falls through to key + * cleanup and regeneration instead of permanently blocking the user. + */ + Log.e(TAG, "RSA key padding mismatch detected (Android 12+ Keystore2).", e); + deleteAESKeys(); + throw new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", e); } catch (IllegalBlockSizeException | BadPaddingException e) { /* * They really should not be thrown at all since padding is requested in the transformation. @@ -467,10 +481,12 @@ private byte[] attemptPKCS1Migration(byte[] encryptedAESBytes) { } catch (BadPaddingException | IllegalBlockSizeException e) { Log.e(TAG, "PKCS1 decryption failed. Data may be corrupted.", e); - } catch (KeyStoreException | CertificateException | IOException | + } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException | UnrecoverableEntryException | NoSuchPaddingException | InvalidKeyException e) { Log.e(TAG, "Migration failed due to key access error.", e); + } catch (ProviderException e) { + Log.e(TAG, "PKCS1 migration failed: key padding incompatible (Android 12+ Keystore2).", e); } catch (CryptoException e) { Log.e(TAG, "Failed to re-encrypt AES key with OAEP.", e); } @@ -593,7 +609,9 @@ private byte[] tryMigrateLegacyAESKey() { KeyStore.PrivateKeyEntry rsaKeyEntry = getRSAKeyEntry(); byte[] decryptedAESKey = RSADecryptLegacyPKCS1(encryptedOldAESBytes, rsaKeyEntry.getPrivateKey()); - + + deleteRSAKeys(); + // Re-encrypt with OAEP and store at new location byte[] encryptedAESWithOAEP = RSAEncrypt(decryptedAESKey); String newEncodedEncryptedAES = new String(Base64.encode(encryptedAESWithOAEP, Base64.DEFAULT), StandardCharsets.UTF_8); @@ -603,7 +621,8 @@ private byte[] tryMigrateLegacyAESKey() { Log.d(TAG, "Legacy AES key migrated successfully"); return decryptedAESKey; } catch (CryptoException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | - BadPaddingException | IllegalBlockSizeException | IllegalArgumentException e) { + BadPaddingException | IllegalBlockSizeException | IllegalArgumentException | + ProviderException e) { Log.e(TAG, "Could not migrate legacy AES key. Will generate new key.", e); deleteAESKeys(); return null; @@ -632,8 +651,11 @@ private byte[] generateNewAESKey() throws IncompatibleDeviceException, CryptoExc } catch (NoSuchAlgorithmException e) { Log.e(TAG, "AES algorithm not available.", e); throw new IncompatibleDeviceException(e); + } catch (IncompatibleDeviceException e) { + deleteRSAKeys(); + deleteAESKeys(); + throw e; } catch (CryptoException e) { - // Re-throw CryptoException and its subclasses (including IncompatibleDeviceException) throw e; } catch (Exception e) { Log.e(TAG, "Unexpected error while creating new AES key.", e); diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index a6e86c492..f3782c2a8 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -6,10 +6,11 @@ import android.util.Base64 import android.util.Log import androidx.annotation.VisibleForTesting import androidx.fragment.app.FragmentActivity -import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException import com.auth0.android.request.internal.GsonProvider import com.auth0.android.result.APICredentials import com.auth0.android.result.Credentials @@ -28,8 +29,9 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException /** - * A safer alternative to the [CredentialsManager] class. A combination of RSA and AES keys is used to keep the values secure. - * On devices with a Secure LockScreen configured (PIN, Pattern, Password or Fingerprint) an extra authentication step can be required. + * A safer alternative to the [CredentialsManager] class. A combination of RSA and AES keys is used + * to keep the values secure. On devices with a Secure LockScreen configured (PIN, Pattern, Password + * or Fingerprint) an extra authentication step can be required. */ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( apiClient: AuthenticationAPIClient, @@ -47,92 +49,39 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT private val lastBiometricAuthTime = AtomicLong(NO_SESSION) /** - * Creates a new SecureCredentialsManager to handle Credentials + * Creates a new SecureCredentialsManager to handle Credentials. * - * @param context a valid context - * @param auth0 the Auth0 account information to use - * @param storage the storage implementation to use - */ - public constructor( - context: Context, - auth0: Auth0, - storage: Storage, - ) : this( - AuthenticationAPIClient(auth0), - context, - auth0, - storage - ) - - /** - * Creates a new SecureCredentialsManager to handle Credentials with a custom AuthenticationAPIClient instance. - * Use this constructor when you need to configure the API client with advanced features like DPoP. - * - * Example usage: + * To enable DPoP, configure the [apiClient] before passing it in: * ``` - * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") * val apiClient = AuthenticationAPIClient(auth0).useDPoP(context) - * val manager = SecureCredentialsManager(apiClient, context, auth0, storage) + * val manager = SecureCredentialsManager(apiClient, context, storage) * ``` * * @param apiClient a configured AuthenticationAPIClient instance * @param context a valid context - * @param auth0 the Auth0 account information to use * @param storage the storage implementation to use */ public constructor( apiClient: AuthenticationAPIClient, context: Context, - auth0: Auth0, - storage: Storage + storage: Storage, ) : this( apiClient, storage, CryptoUtil(context, storage, KEY_ALIAS), JWTDecoder(), - auth0.executor + apiClient.executor ) - /** - * Creates a new SecureCredentialsManager to handle Credentials with biometrics Authentication + * Creates a new SecureCredentialsManager to handle Credentials with biometric authentication. * - * @param context a valid context - * @param auth0 the Auth0 account information to use - * @param storage the storage implementation to use - * @param fragmentActivity the FragmentActivity to use for the biometric authentication - * @param localAuthenticationOptions the options of type [LocalAuthenticationOptions] to use for the biometric authentication - */ - public constructor( - context: Context, - auth0: Auth0, - storage: Storage, - fragmentActivity: FragmentActivity, - localAuthenticationOptions: LocalAuthenticationOptions - ) : this( - AuthenticationAPIClient(auth0), - context, - auth0, - storage, - fragmentActivity, - localAuthenticationOptions - ) - - - /** - * Creates a new SecureCredentialsManager to handle Credentials with biometrics Authentication - * and a custom AuthenticationAPIClient instance. - * Use this constructor when you need to configure the API client with advanced features like DPoP - * along with biometric authentication. - * - * Example usage: + * To enable DPoP, configure the [apiClient] before passing it in: * ``` - * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") * val apiClient = AuthenticationAPIClient(auth0).useDPoP(context) * val manager = SecureCredentialsManager( * apiClient, * context, - * auth0, * storage, * fragmentActivity, * localAuthenticationOptions @@ -141,7 +90,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * * @param apiClient a configured AuthenticationAPIClient instance * @param context a valid context - * @param auth0 the Auth0 account information to use * @param storage the storage implementation to use * @param fragmentActivity the FragmentActivity to use for the biometric authentication * @param localAuthenticationOptions the options of type [LocalAuthenticationOptions] to use for the biometric authentication @@ -149,7 +97,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT public constructor( apiClient: AuthenticationAPIClient, context: Context, - auth0: Auth0, storage: Storage, fragmentActivity: FragmentActivity, localAuthenticationOptions: LocalAuthenticationOptions @@ -158,7 +105,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT storage, CryptoUtil(context, storage, KEY_ALIAS), JWTDecoder(), - auth0.executor, + apiClient.executor, WeakReference(fragmentActivity), localAuthenticationOptions, DefaultLocalAuthenticationManagerFactory() @@ -168,15 +115,23 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT /** * Saves the given credentials in the Storage. * + * **Important:** This method is not thread safe + * * @param credentials the credentials to save. - * @throws CredentialsManagerException if the credentials couldn't be encrypted. Some devices are not compatible at all with the cryptographic + * @throws CredentialsManagerException with code `SESSION_EXPIRED` if the credentials carry an + * IPSIE `session_expiry` claim that is already past its ceiling at creation time, with code + * `INVALID_CREDENTIALS` if neither an access_token nor an id_token is present, or if the + * credentials couldn't be encrypted. Some devices are not compatible at all with the cryptographic * implementation and will have [CredentialsManagerException.isDeviceIncompatible] return true. + * */ @Throws(CredentialsManagerException::class) override fun saveCredentials(credentials: Credentials) { if (TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken)) { throw CredentialsManagerException.INVALID_CREDENTIALS } + // IPSIE session_expiry: reject a session already past its ceiling at creation time. + validateSessionExpiryAtCreation(credentials.idToken) val json = gson.toJson(credentials) val canRefresh = !TextUtils.isEmpty(credentials.refreshToken) Log.d(TAG, "Trying to encrypt the given data using the private key.") @@ -189,6 +144,11 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT ) storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time) storage.store(KEY_CAN_REFRESH, canRefresh) + storage.store(KEY_TOKEN_TYPE, credentials.type) + // Preserve the session_expiry ceiling across refreshes: only ever written, never cleared, + // so a refresh whose ID token omits the claim does not silently remove the limit. + persistSessionExpiry(credentials.idToken) + saveDPoPThumbprint(credentials) } catch (e: IncompatibleDeviceException) { throw CredentialsManagerException( CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e @@ -207,10 +167,13 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT /** * Stores the given [APICredentials] in the storage for the given audience. + * + * **Important:** This method is not thread safe * @param apiCredentials the API Credentials to be stored * @param audience the audience for which the credentials are stored * @param scope the scope for which the credentials are stored */ + @Throws(CredentialsManagerException::class) override fun saveApiCredentials( apiCredentials: APICredentials, audience: String, @@ -244,12 +207,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. */ @@ -265,11 +222,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -279,53 +231,57 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback: Callback ) { serialExecutor.execute { - val existingCredentials: Credentials = try { - getExistingCredentials() - } catch (exception: CredentialsManagerException) { - Log.e(TAG, "Error while fetching existing credentials", exception) - callback.onFailure(exception) - return@execute - } - if (existingCredentials.refreshToken.isNullOrEmpty()) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - - val request = - authenticationClient.ssoExchange(existingCredentials.refreshToken) - try { - if (parameters.isNotEmpty()) { - request.addParameters(parameters) + runCatchingOnExecutor(callback) { + val existingCredentials: Credentials = try { + getExistingCredentials() + } catch (exception: CredentialsManagerException) { + Log.e(TAG, "Error while fetching existing credentials", exception) + callback.onFailure(exception) + return@execute } - val sessionCredentials = request.execute() - saveSsoCredentials(sessionCredentials) - callback.onSuccess(sessionCredentials) - } catch (error: AuthenticationException) { - val exception = when { - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.SSO_EXCHANGE_FAILED + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before exchanging + // the refresh token, so the SSO exchange is never used to outlive the session. + if (isSessionExpired(existingCredentials.idToken)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } + if (existingCredentials.refreshToken.isNullOrEmpty()) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute + } + + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute } - callback.onFailure( - CredentialsManagerException( - exception, error + + val request = + authenticationClient.ssoExchange(existingCredentials.refreshToken) + try { + if (parameters.isNotEmpty()) { + request.addParameters(parameters) + } + val sessionCredentials = request.execute() + saveSsoCredentials(sessionCredentials) + callback.onSuccess(sessionCredentials) + } catch (error: AuthenticationException) { + val exception = when { + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.SSO_EXCHANGE_FAILED + } + callback.onFailure( + CredentialsManagerException( + exception, error + ) ) - ) - } catch (error: CredentialsManagerException) { - val exception = CredentialsManagerException( - CredentialsManagerException.Code.STORE_FAILED, error - ) - callback.onFailure(exception) - } catch (exception: RuntimeException) { - Log.e( - TAG, - "Caught unexpected exceptions while fetching sso token ${exception.stackTraceToString()}" - ) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.UNKNOWN_ERROR, - exception + } catch (error: CredentialsManagerException) { + val exception = CredentialsManagerException( + CredentialsManagerException.Code.STORE_FAILED, error ) - ) + callback.onFailure(exception) + } } } } @@ -346,12 +302,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. */ @@ -369,12 +319,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. */ @@ -409,7 +353,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT @JvmSynthetic @Throws(CredentialsManagerException::class) override suspend fun awaitCredentials(): Credentials { - return awaitCredentials(null, 0) + return awaitCredentials(null, DEFAULT_MIN_TTL) } /** @@ -579,7 +523,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT override fun getCredentials( callback: Callback ) { - getCredentials(null, 0, callback) + getCredentials(null, DEFAULT_MIN_TTL, callback) } /** @@ -674,6 +618,15 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT forceRefresh: Boolean, callback: Callback ) { + // IPSIE session_expiry: short-circuit before any biometric prompt or refresh. The ceiling is + // read from the value persisted at login (KEY_SESSION_EXPIRY); past it we clear and surface the + // dedicated error rather than prompting biometrics for a session that can no longer be served. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return + } + if (!hasValidCredentials(minTtl.toLong())) { callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) return @@ -725,6 +678,14 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT headers: Map, callback: Callback ) { + // IPSIE session_expiry: short-circuit before any biometric prompt or refresh. The ceiling is + // read from the value persisted at login (KEY_SESSION_EXPIRY); past it we clear and surface the + // dedicated error rather than prompting biometrics for a session that can no longer be served. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return + } if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) { @@ -754,14 +715,27 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * Delete the stored credentials */ override fun clearCredentials() { - storage.remove(KEY_CREDENTIALS) - storage.remove(KEY_EXPIRES_AT) - storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT) - storage.remove(KEY_CAN_REFRESH) + storage.removeAll() clearBiometricSession() Log.d(TAG, "Credentials were just removed from the storage") } + /** + * Removes all credentials, API credentials, and cryptographic key pairs. + * This calls [Storage.removeAll] to clear all stored data + */ + override fun clearAll() { + storage.removeAll() + crypto.deleteAllKeys() + clearBiometricSession() + try { + DPoP.clearKeyPair() + } catch (e: DPoPException) { + Log.e(TAG, "Failed to clear DPoP key pair ${e.stackTraceToString()}") + } + Log.d(TAG, "All credentials and key pairs were removed") + } + /** * Removes the credentials for the given audience from the storage if present. * @param audience Audience for which the [APICredentials] are stored @@ -779,7 +753,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * @return whether this manager contains a valid non-expired pair of credentials or not. */ override fun hasValidCredentials(): Boolean { - return hasValidCredentials(0) + return hasValidCredentials(DEFAULT_MIN_TTL.toLong()) } /** @@ -797,9 +771,16 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } val canRefresh = storage.retrieveBoolean(KEY_CAN_REFRESH) val emptyCredentials = TextUtils.isEmpty(encryptedEncoded) - return !(emptyCredentials || willExpire( - expiresAt, minTtl - ) && (canRefresh == null || !canRefresh)) + if (emptyCredentials) { + return false + } + // IPSIE session_expiry: once the upstream-IdP ceiling passes, no valid credentials remain. + // The credentials blob is encrypted and cannot be decoded here, so the ceiling is read from + // the value persisted at login (KEY_SESSION_EXPIRY) via isSessionExpired(null). + if (isSessionExpired(null)) { + return false + } + return !(willExpire(expiresAt, minTtl) && (canRefresh == null || !canRefresh)) } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @@ -812,157 +793,158 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback: Callback ) { serialExecutor.execute { - val encryptedEncoded = storage.retrieveString(KEY_CREDENTIALS) - if (encryptedEncoded.isNullOrBlank()) { - callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) - return@execute - } - val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) - val json: String - try { - json = String(crypto.decrypt(encrypted)) - } catch (e: IncompatibleDeviceException) { - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e + runCatchingOnExecutor(callback) { + val encryptedEncoded = storage.retrieveString(KEY_CREDENTIALS) + if (encryptedEncoded.isNullOrBlank()) { + callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) + return@execute + } + val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) + val json: String + try { + json = String(crypto.decrypt(encrypted)) + } catch (e: IncompatibleDeviceException) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e + ) ) - ) - return@execute - } catch (e: CryptoException) { - //If keys were invalidated, existing credentials will not be recoverable. - clearCredentials() - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.CRYPTO_EXCEPTION, e + return@execute + } catch (e: CryptoException) { + //If keys were invalidated, existing credentials will not be recoverable. + clearCredentials() + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.CRYPTO_EXCEPTION, e + ) ) + return@execute + } + val bridgeCredentials = gson.fromJson(json, OptionalCredentials::class.java)/* OPTIONAL CREDENTIALS + * This bridge is required to prevent users from being logged out when + * migrating from Credentials with optional Access Token and ID token + */ + val credentials = Credentials( + bridgeCredentials.idToken.orEmpty(), + bridgeCredentials.accessToken.orEmpty(), + bridgeCredentials.type.orEmpty(), + bridgeCredentials.refreshToken, + bridgeCredentials.expiresAt ?: Date(), + bridgeCredentials.scope + ) + val expiresAt = credentials.expiresAt.time + val hasEmptyCredentials = + TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken) + if (hasEmptyCredentials) { + callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) + return@execute + } + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving any + // cached token or attempting a refresh. Past the ceiling, clear and surface the error + // so the refresh-token grant is never used to outlive the session. + if (isSessionExpired(credentials.idToken)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + val scopeChanged = hasScopeChanged(credentials.scope, scope) + if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { + callback.onSuccess(stampPinnedSessionExpiry(credentials)) + return@execute + } + if (credentials.refreshToken == null) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute + } + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: credentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + Log.d(TAG, "Credentials have expired. Renewing them now...") + val request = authenticationClient.renewAuth( + credentials.refreshToken ) - return@execute - } - val bridgeCredentials = gson.fromJson(json, OptionalCredentials::class.java)/* OPTIONAL CREDENTIALS - * This bridge is required to prevent users from being logged out when - * migrating from Credentials with optional Access Token and ID token - */ - val credentials = Credentials( - bridgeCredentials.idToken.orEmpty(), - bridgeCredentials.accessToken.orEmpty(), - bridgeCredentials.type.orEmpty(), - bridgeCredentials.refreshToken, - bridgeCredentials.expiresAt ?: Date(), - bridgeCredentials.scope - ) - val expiresAt = credentials.expiresAt.time - val hasEmptyCredentials = - TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken) - if (hasEmptyCredentials) { - callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) - return@execute - } - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - val scopeChanged = hasScopeChanged(credentials.scope, scope) - if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { - callback.onSuccess(credentials) - return@execute - } - if (credentials.refreshToken == null) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - Log.d(TAG, "Credentials have expired. Renewing them now...") - val request = authenticationClient.renewAuth( - credentials.refreshToken - ) - request.addParameters(parameters) - if (scope != null) { - request.addParameter("scope", scope) - } + request.addParameters(parameters) + if (scope != null) { + request.addParameter("scope", scope) + } - for (header in headers) { - request.addHeader(header.key, header.value) - } + for (header in headers) { + request.addHeader(header.key, header.value) + } - val freshCredentials: Credentials - try { - val fresh = request.execute() - val expiresAt = fresh.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - if (willAccessTokenExpire) { - val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 - val wrongTtlException = CredentialsManagerException( - CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( - Locale.getDefault(), - "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", - tokenLifetime, - minTtl + val freshCredentials: Credentials + try { + val fresh = request.execute() + val expiresAt = fresh.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + if (willAccessTokenExpire) { + val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 + val wrongTtlException = CredentialsManagerException( + CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( + Locale.getDefault(), + "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + tokenLifetime, + minTtl + ) ) + callback.onFailure(wrongTtlException) + return@execute + } + + //non-empty refresh token for refresh token rotation scenarios + val updatedRefreshToken = + if (TextUtils.isEmpty(fresh.refreshToken)) credentials.refreshToken else fresh.refreshToken + freshCredentials = Credentials( + fresh.idToken, + fresh.accessToken, + fresh.type, + updatedRefreshToken, + fresh.expiresAt, + fresh.scope ) - callback.onFailure(wrongTtlException) - return@execute - } + } catch (error: AuthenticationException) { + if (error.isMultifactorRequired) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.MFA_REQUIRED, + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", + error, + error.mfaRequiredErrorPayload + ) + ) + return@execute + } + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - //non-empty refresh token for refresh token rotation scenarios - val updatedRefreshToken = - if (TextUtils.isEmpty(fresh.refreshToken)) credentials.refreshToken else fresh.refreshToken - freshCredentials = Credentials( - fresh.idToken, - fresh.accessToken, - fresh.type, - updatedRefreshToken, - fresh.expiresAt, - fresh.scope - ) - } catch (error: AuthenticationException) { - if (error.isMultifactorRequired) { + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } callback.onFailure( CredentialsManagerException( - CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", - error, - error.mfaRequiredErrorPayload + exception, error ) ) return@execute } - val exception = when { - error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.API_ERROR - } - callback.onFailure( - CredentialsManagerException( - exception, error + try { + saveCredentials(freshCredentials) + callback.onSuccess(stampPinnedSessionExpiry(freshCredentials)) + } catch (error: CredentialsManagerException) { + val exception = CredentialsManagerException( + CredentialsManagerException.Code.STORE_FAILED, error ) - ) - return@execute - } catch (exception: RuntimeException) { - /** - * Catching any unexpected runtime errors in the token renewal flow - */ - Log.e( - TAG, - "Caught unexpected exceptions for token renewal ${exception.stackTraceToString()}" - ) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.UNKNOWN_ERROR, - exception - ) - ) - return@execute - } - - try { - saveCredentials(freshCredentials) - callback.onSuccess(freshCredentials) - } catch (error: CredentialsManagerException) { - val exception = CredentialsManagerException( - CredentialsManagerException.Code.STORE_FAILED, error - ) - if (error.cause is IncompatibleDeviceException || error.cause is CryptoException) { - exception.refreshedCredentials = freshCredentials + if (error.cause is IncompatibleDeviceException || error.cause is CryptoException) { + exception.refreshedCredentials = freshCredentials + } + callback.onFailure(exception) } - callback.onFailure(exception) } } } @@ -978,121 +960,149 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback: Callback ) { serialExecutor.execute { - val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope)) - //Check if existing api credentials are present and valid - - encryptedEncodedJson?.let { encryptedEncoded -> - val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) - val json: String = try { - String(crypto.decrypt(encrypted)) - } catch (e: IncompatibleDeviceException) { - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, - e - ) - ) + runCatchingOnExecutor(callback) { + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving + // cached API credentials or exchanging the refresh token. The ceiling is read from + // the value persisted at login (KEY_SESSION_EXPIRY) so it holds even though the + // credentials blob is encrypted; past it we clear and surface the dedicated error. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) return@execute - } catch (e: CryptoException) { - clearApiCredentials(audience, scope) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.CRYPTO_EXCEPTION, - e + } + val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope)) + //Check if existing api credentials are present and valid + + var apiCredentialType: String? = null + encryptedEncodedJson?.let { encryptedEncoded -> + val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) + val json: String = try { + String(crypto.decrypt(encrypted)) + } catch (e: IncompatibleDeviceException) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, + e + ) + ) + return@execute + } catch (e: CryptoException) { + clearApiCredentials(audience, scope) + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.CRYPTO_EXCEPTION, + e + ) ) + return@execute + } + + val apiCredentials = gson.fromJson(json, APICredentials::class.java) + apiCredentialType = apiCredentials.type + + val expiresAt = apiCredentials.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + val scopeChanged = hasScopeChanged( + apiCredentials.scope, scope, + ignoreOpenid = scope?.contains("openid") == false ) - return@execute + val hasExpired = hasExpired(apiCredentials.expiresAt.time) + if (!hasExpired && !willAccessTokenExpire && !scopeChanged) { + callback.onSuccess(apiCredentials) + return@execute + } } - val apiCredentials = gson.fromJson(json, APICredentials::class.java) - - val expiresAt = apiCredentials.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - val scopeChanged = hasScopeChanged( - apiCredentials.scope, scope, - ignoreOpenid = scope?.contains("openid") == false - ) - val hasExpired = hasExpired(apiCredentials.expiresAt.time) - if (!hasExpired && !willAccessTokenExpire && !scopeChanged) { - callback.onSuccess(apiCredentials) + //Check if refresh token exists or not + val existingCredentials: Credentials = try { + getExistingCredentials() + } catch (exception: CredentialsManagerException) { + callback.onFailure(exception) + return@execute + } + val refreshToken = existingCredentials.refreshToken + if (refreshToken == null) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) return@execute } - } - //Check if refresh token exists or not - val existingCredentials: Credentials = try { - getExistingCredentials() - } catch (exception: CredentialsManagerException) { - callback.onFailure(exception) - return@execute - } - val refreshToken = existingCredentials.refreshToken - if (refreshToken == null) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } + val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) + ?: existingCredentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } - val request = authenticationClient.renewAuth(refreshToken, audience, scope) - request.addParameters(parameters) - for (header in headers) { - request.addHeader(header.key, header.value) - } + val request = authenticationClient.renewAuth(refreshToken, audience, scope) + request.addParameters(parameters) + for (header in headers) { + request.addHeader(header.key, header.value) + } - try { - val newCredentials = request.execute() - val expiresAt = newCredentials.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - if (willAccessTokenExpire) { - val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 - val wrongTtlException = CredentialsManagerException( - CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( - Locale.getDefault(), - "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", - tokenLifetime, - minTtl + val newApiCredentials: APICredentials + try { + val newCredentials = request.execute() + val expiresAt = newCredentials.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + if (willAccessTokenExpire) { + val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 + val wrongTtlException = CredentialsManagerException( + CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( + Locale.getDefault(), + "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + tokenLifetime, + minTtl + ) ) - ) - callback.onFailure(wrongTtlException) - return@execute - } + callback.onFailure(wrongTtlException) + return@execute + } - // non-empty refresh token for refresh token rotation scenarios - val updatedRefreshToken = - if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken - val newApiCredentials = newCredentials.toAPICredentials() - saveCredentials( - existingCredentials.copy( - refreshToken = updatedRefreshToken, - idToken = newCredentials.idToken + // non-empty refresh token for refresh token rotation scenarios + val updatedRefreshToken = + if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken + newApiCredentials = newCredentials.toAPICredentials() + saveCredentials( + existingCredentials.copy( + refreshToken = updatedRefreshToken, + idToken = newCredentials.idToken + ) ) - ) - saveApiCredentials(newApiCredentials, audience, scope) - callback.onSuccess(newApiCredentials) - - } catch (error: AuthenticationException) { - if (error.isMultifactorRequired) { + saveApiCredentials(newApiCredentials, audience, scope) + } catch (error: AuthenticationException) { + if (error.isMultifactorRequired) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.MFA_REQUIRED, + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", + error, + error.mfaRequiredErrorPayload + ) + ) + return@execute + } + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } callback.onFailure( CredentialsManagerException( - CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", - error, - error.mfaRequiredErrorPayload + exception, error ) ) return@execute - } - val exception = when { - error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.API_ERROR - } - callback.onFailure( - CredentialsManagerException( - exception, error + } catch (error: CredentialsManagerException) { + val storeException = CredentialsManagerException( + CredentialsManagerException.Code.STORE_FAILED, error ) - ) - } + callback.onFailure(storeException) + return@execute + } + callback.onSuccess(newApiCredentials) + } } } @@ -1274,6 +1284,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal const val KEY_ALIAS = "com.auth0.key" + // Using NO_SESSION to represent "no session" (uninitialized state) private const val NO_SESSION = -1L } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SharedPreferencesStorage.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SharedPreferencesStorage.kt index b9c5bb8d4..72ebfebdc 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SharedPreferencesStorage.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SharedPreferencesStorage.kt @@ -75,6 +75,10 @@ public class SharedPreferencesStorage @JvmOverloads constructor( sp.edit().remove(name).apply() } + override fun removeAll() { + sp.edit().clear().apply() + } + private companion object { private const val SHARED_PREFERENCES_NAME = "com.auth0.authentication.storage" } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/Storage.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/Storage.kt index f699cd49e..d2e788f2a 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/Storage.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/Storage.kt @@ -75,4 +75,9 @@ public interface Storage { * @param name the name of the value to remove. */ public fun remove(name: String) + + /** + * Removes all values from the storage. + */ + public fun removeAll() {} } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/callback/ManagementCallback.kt b/auth0/src/main/java/com/auth0/android/callback/ManagementCallback.kt deleted file mode 100644 index 11969adb8..000000000 --- a/auth0/src/main/java/com/auth0/android/callback/ManagementCallback.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.auth0.android.callback - -import com.auth0.android.management.ManagementException - -public interface ManagementCallback : Callback \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt index d84f7f610..b0490575e 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt @@ -198,6 +198,29 @@ public class DPoP(context: Context) { return HeaderData(token, proof) } + /** + * Returns whether a DPoP key pair currently exists in the Android KeyStore. + * + * This can be used to check if DPoP credentials are still available after events + * like device backup/restore or factory reset, which do not preserve KeyStore entries. + * + * ```kotlin + * + * if (!DPoP.hasKeyPair()) { + * // Key was lost — clear stored credentials and re-authenticate + * } + * + * ``` + * + * @return true if a DPoP key pair exists in the KeyStore, false otherwise. + * @throws DPoPException if there is an error accessing the KeyStore. + */ + @Throws(DPoPException::class) + @JvmStatic + public fun hasKeyPair(): Boolean { + return DPoPUtil.hasKeyPair() + } + /** * Method to clear the DPoP key pair from the keystore. It must be called when the user logs out * to prevent reuse of the key pair in subsequent sessions. diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt index c94294512..1d483bfd8 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPException.kt @@ -5,7 +5,6 @@ import com.auth0.android.Auth0Exception public class DPoPException : Auth0Exception { internal enum class Code { - UNSUPPORTED_ERROR, KEY_GENERATION_ERROR, KEY_STORE_ERROR, SIGNING_ERROR, @@ -39,7 +38,6 @@ public class DPoPException : Auth0Exception { public companion object { - public val UNSUPPORTED_ERROR :DPoPException = DPoPException(Code.UNSUPPORTED_ERROR) public val KEY_GENERATION_ERROR: DPoPException = DPoPException(Code.KEY_GENERATION_ERROR) public val KEY_STORE_ERROR: DPoPException = DPoPException(Code.KEY_STORE_ERROR) public val SIGNING_ERROR: DPoPException = DPoPException(Code.SIGNING_ERROR) @@ -52,7 +50,6 @@ public class DPoPException : Auth0Exception { private fun getMessage(code: Code): String { return when (code) { - Code.UNSUPPORTED_ERROR -> "DPoP is not supported in versions below Android 9 (API level 28)." Code.KEY_GENERATION_ERROR -> "Error generating DPoP key pair." Code.KEY_STORE_ERROR -> "Error while accessing the key pair in the keystore." Code.SIGNING_ERROR -> "Error while signing the DPoP proof." diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt index 742e5beaf..234d9b806 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt @@ -6,19 +6,21 @@ import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Log +import java.io.IOException import java.security.InvalidAlgorithmParameterException import java.security.KeyPairGenerator import java.security.KeyStore import java.security.KeyStoreException import java.security.NoSuchAlgorithmException import java.security.NoSuchProviderException +import java.security.UnrecoverableKeyException import java.security.PrivateKey import java.security.ProviderException import java.security.PublicKey +import java.security.cert.CertificateException import java.security.spec.ECGenParameterSpec import java.util.Calendar import javax.security.auth.x500.X500Principal -import javax.security.cert.CertificateException /** * Class to handle all DPoP related keystore operations @@ -30,9 +32,6 @@ internal open class DPoPKeyStore { } fun generateKeyPair(context: Context, useStrongBox: Boolean = true) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - throw DPoPException.UNSUPPORTED_ERROR - } try { val keyPairGenerator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, @@ -97,8 +96,16 @@ internal open class DPoPKeyStore { if (publicKey != null) { return Pair(privateKey, publicKey) } - } catch (e: KeyStoreException) { - throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } catch (e: Exception) { + when (e) { + is KeyStoreException, + is NoSuchAlgorithmException, + is UnrecoverableKeyException, + is ClassCastException, + is IOException, + is CertificateException -> throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + else -> throw DPoPException(DPoPException.Code.UNKNOWN_ERROR, e) + } } Log.d(TAG, "Returning null key pair ") return null @@ -107,16 +114,28 @@ internal open class DPoPKeyStore { fun hasKeyPair(): Boolean { try { return keyStore.containsAlias(KEY_ALIAS) - } catch (e: KeyStoreException) { - throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } catch (e: Exception) { + when (e) { + is KeyStoreException, + is NoSuchAlgorithmException, + is IOException, + is CertificateException -> throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + else -> throw DPoPException(DPoPException.Code.UNKNOWN_ERROR, e) + } } } fun deleteKeyPair() { try { keyStore.deleteEntry(KEY_ALIAS) - } catch (e: KeyStoreException) { - throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } catch (e: Exception) { + when (e) { + is KeyStoreException, + is NoSuchAlgorithmException, + is IOException, + is CertificateException -> throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + else -> throw DPoPException(DPoPException.Code.UNKNOWN_ERROR, e) + } } } diff --git a/auth0/src/main/java/com/auth0/android/management/ManagementException.kt b/auth0/src/main/java/com/auth0/android/management/ManagementException.kt deleted file mode 100644 index c74da0946..000000000 --- a/auth0/src/main/java/com/auth0/android/management/ManagementException.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.auth0.android.management - -import com.auth0.android.Auth0Exception -import com.auth0.android.NetworkErrorException - -public class ManagementException @JvmOverloads constructor( - message: String, - exception: Auth0Exception? = null -) : Auth0Exception(message, exception) { - private var code: String? = null - private var description: String? = null - - /** - * Http Response status code. Can have value of 0 if not set. - * - * @return the status code. - */ - public var statusCode: Int = 0 - private set - private var values: Map? = null - - public constructor(payload: String?, statusCode: Int) : this(DEFAULT_MESSAGE) { - code = if (payload != null) NON_JSON_ERROR else EMPTY_BODY_ERROR - description = payload ?: EMPTY_RESPONSE_BODY_DESCRIPTION - this.statusCode = statusCode - } - - public constructor(values: Map) : this(DEFAULT_MESSAGE) { - this.values = values - val codeValue = - (if (values.containsKey(ERROR_KEY)) values[ERROR_KEY] else values[CODE_KEY]) as String? - code = codeValue ?: UNKNOWN_ERROR - description = - (if (values.containsKey(DESCRIPTION_KEY)) values[DESCRIPTION_KEY] else values[ERROR_DESCRIPTION_KEY]) as String? - } - - /** - * Auth0 error code if the server returned one or an internal library code (e.g.: when the server could not be reached) - * - * @return the error code. - */ - @Suppress("MemberVisibilityCanBePrivate") - public fun getCode(): String { - return if (code != null) code!! else UNKNOWN_ERROR - } - - /** - * Description of the error. - * important: You should avoid displaying description to the user, it's meant for debugging only. - * - * @return the error description. - */ - @Suppress("unused") - public fun getDescription(): String { - if (description != null) { - return description!! - } - return if (UNKNOWN_ERROR == getCode()) { - String.format("Received error with code %s", getCode()) - } else "Failed with unknown error" - } - - /** - * Returns a value from the error map, if any. - * - * @param key key of the value to return - * @return the value if found or null - */ - public fun getValue(key: String): Any? { - return values?.get(key) - } - - // When the request failed due to network issues - public val isNetworkError: Boolean - get() = cause is NetworkErrorException - - private companion object { - private const val ERROR_KEY = "error" - private const val CODE_KEY = "code" - private const val DESCRIPTION_KEY = "description" - private const val ERROR_DESCRIPTION_KEY = "error_description" - private const val DEFAULT_MESSAGE = - "An error occurred when trying to authenticate with the server." - } -} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt deleted file mode 100755 index 43f23af44..000000000 --- a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt +++ /dev/null @@ -1,251 +0,0 @@ -package com.auth0.android.management - -import androidx.annotation.VisibleForTesting -import com.auth0.android.Auth0 -import com.auth0.android.Auth0Exception -import com.auth0.android.NetworkErrorException -import com.auth0.android.authentication.ParameterBuilder -import com.auth0.android.request.ErrorAdapter -import com.auth0.android.request.JsonAdapter -import com.auth0.android.request.NetworkingClient -import com.auth0.android.request.Request -import com.auth0.android.request.internal.BaseRequest -import com.auth0.android.request.internal.GsonAdapter -import com.auth0.android.request.internal.GsonAdapter.Companion.forListOf -import com.auth0.android.request.internal.GsonAdapter.Companion.forMap -import com.auth0.android.request.internal.GsonProvider -import com.auth0.android.request.internal.RequestFactory -import com.auth0.android.request.internal.ResponseUtils.isNetworkError -import com.auth0.android.result.UserIdentity -import com.auth0.android.result.UserProfile -import com.google.gson.Gson -import okhttp3.HttpUrl.Companion.toHttpUrl -import java.io.IOException -import java.io.Reader - -/** - * API client for Auth0 Management API. - * ``` - * val auth0 = Auth0.getInstance("your_client_id", "your_domain") - * val client = UsersAPIClient(auth0) - * ``` - * - * @see [Auth API docs](https://auth0.com/docs/api/management/v2) - */ -public class UsersAPIClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( - private val auth0: Auth0, - private val factory: RequestFactory, - private val gson: Gson -) { - /** - * Creates a new API client instance providing the Auth0 account info and the access token. - * - * @param auth0 account information - * @param token of the primary identity - */ - public constructor( - auth0: Auth0, - token: String - ) : this( - auth0, - factoryForToken(token, auth0.networkingClient), - GsonProvider.gson - ) - - public val clientId: String - get() = auth0.clientId - public val baseURL: String - get() = auth0.getDomainUrl() - - /** - * Link a user identity calling ['/api/v2/users/:primaryUserId/identities'](https://auth0.com/docs/link-accounts#the-management-api) endpoint - * Example usage: - * ``` - * client.link("{auth0 primary user id}", "{user secondary token}") - * .start(object: Callback, ManagementException> { - * override fun onSuccess(result: List) { } - * override fun onFailure(error: ManagementException) { } - * }) - * ``` - * - * @param primaryUserId of the identity to link - * @param secondaryToken of the secondary identity obtained after login - * @return a request to start - */ - public fun link( - primaryUserId: String, - secondaryToken: String - ): Request, ManagementException> { - val url = auth0.getDomainUrl().toHttpUrl().newBuilder() - .addPathSegment(API_PATH) - .addPathSegment(V2_PATH) - .addPathSegment(USERS_PATH) - .addPathSegment(primaryUserId) - .addPathSegment(IDENTITIES_PATH) - .build() - val parameters = ParameterBuilder.newBuilder() - .set(LINK_WITH_KEY, secondaryToken) - .asDictionary() - val userIdentitiesAdapter: JsonAdapter> = forListOf( - UserIdentity::class.java, gson - ) - return factory.post(url.toString(), userIdentitiesAdapter) - .addParameters(parameters) - } - - /** - * Unlink a user identity calling ['/api/v2/users/:primaryToken/identities/secondaryProvider/secondaryUserId'](https://auth0.com/docs/link-accounts#unlinking-accounts) endpoint - * Example usage: - * ``` - * client.unlink("{auth0 primary user id}", {auth0 secondary user id}, "{secondary provider}") - * .start(object: Callback, ManagementException> { - * override fun onSuccess(result: List) { } - * override fun onFailure(error: ManagementException) {} - * }) - * ``` - * - * @param primaryUserId of the primary identity to unlink - * @param secondaryUserId of the secondary identity you wish to unlink from the main one. - * @param secondaryProvider of the secondary identity you wish to unlink from the main one. - * @return a request to start - */ - public fun unlink( - primaryUserId: String, - secondaryUserId: String, - secondaryProvider: String - ): Request, ManagementException> { - val url = auth0.getDomainUrl().toHttpUrl().newBuilder() - .addPathSegment(API_PATH) - .addPathSegment(V2_PATH) - .addPathSegment(USERS_PATH) - .addPathSegment(primaryUserId) - .addPathSegment(IDENTITIES_PATH) - .addPathSegment(secondaryProvider) - .addPathSegment(secondaryUserId) - .build() - val userIdentitiesAdapter: JsonAdapter> = forListOf( - UserIdentity::class.java, gson - ) - return factory.delete(url.toString(), userIdentitiesAdapter) - } - - /** - * Update the user_metadata calling ['/api/v2/users/:userId'](https://auth0.com/docs/api/management/v2#!/Users/patch_users_by_id) endpoint - * Example usage: - * ``` - * client.updateMetadata("{user id}", "{user metadata}") - * .start(object: Callback { - * override fun onSuccess(result: UserProfile) { } - * override fun onFailure(error: ManagementException) { } - * }) - * ``` - * - * @param userId of the primary identity to unlink - * @param userMetadata to merge with the existing one - * @return a request to start - */ - public fun updateMetadata( - userId: String, - userMetadata: Map - ): Request { - val url = auth0.getDomainUrl().toHttpUrl().newBuilder() - .addPathSegment(API_PATH) - .addPathSegment(V2_PATH) - .addPathSegment(USERS_PATH) - .addPathSegment(userId) - .build() - val userProfileAdapter: JsonAdapter = GsonAdapter( - UserProfile::class.java, gson - ) - val patch = factory.patch( - url.toString(), - userProfileAdapter - ) as BaseRequest - patch.addParameter(USER_METADATA_KEY, userMetadata) - return patch - } - - /** - * Get the User Profile calling ['/api/v2/users/:userId'](https://auth0.com/docs/api/management/v2#!/Users/get_users_by_id) endpoint - * Example usage: - * ``` - * client.getProfile("{user id}") - * .start(object: Callback { - * override fun onSuccess(result: UserProfile) { } - * override fun onFailure(error: ManagementException) { } - * }) - * ``` - * - * @param userId identity of the user - * @return a request to start - */ - public fun getProfile(userId: String): Request { - val url = auth0.getDomainUrl().toHttpUrl().newBuilder() - .addPathSegment(API_PATH) - .addPathSegment(V2_PATH) - .addPathSegment(USERS_PATH) - .addPathSegment(userId) - .build() - val userProfileAdapter: JsonAdapter = GsonAdapter( - UserProfile::class.java, gson - ) - return factory.get(url.toString(), userProfileAdapter) - } - - private companion object { - private const val LINK_WITH_KEY = "link_with" - private const val API_PATH = "api" - private const val V2_PATH = "v2" - private const val USERS_PATH = "users" - private const val IDENTITIES_PATH = "identities" - private const val USER_METADATA_KEY = "user_metadata" - - private fun createErrorAdapter(): ErrorAdapter { - val mapAdapter = forMap(GsonProvider.gson) - return object : ErrorAdapter { - override fun fromRawResponse( - statusCode: Int, - bodyText: String, - headers: Map> - ): ManagementException { - return ManagementException(bodyText, statusCode) - } - - @Throws(IOException::class) - override fun fromJsonResponse( - statusCode: Int, - reader: Reader - ): ManagementException { - val values = mapAdapter.fromJson(reader) - return ManagementException(values) - } - - override fun fromException(cause: Throwable): ManagementException { - if (isNetworkError(cause)) { - return ManagementException( - "Failed to execute the network request", - NetworkErrorException(cause) - ) - } - return ManagementException( - "Something went wrong", - Auth0Exception("Something went wrong", cause) - ) - } - } - } - - private fun factoryForToken( - token: String, - client: NetworkingClient - ): RequestFactory { - val factory = RequestFactory(client, createErrorAdapter()) - factory.setHeader("Authorization", "Bearer $token") - return factory - } - } - - init { - factory.setAuth0ClientInfo(auth0.auth0UserAgent.value) - } -} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt new file mode 100644 index 000000000..52d1d4d58 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt @@ -0,0 +1,16 @@ +package com.auth0.android.myaccount + +/** + * Represents the types of authentication methods supported by the My Account API. + */ +public enum class AuthenticationMethodType(public val type: String) { + PASSWORD("password"), + PASSKEY("passkey"), + TOTP("totp"), + PHONE("phone"), + EMAIL("email"), + PUSH("push-notification"), + RECOVERY_CODE("recovery-code"), + WEBAUTHN_PLATFORM("webauthn-platform"), + WEBAUTHN_ROAMING("webauthn-roaming") +} diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index fa5c2328a..81a1ced6f 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -1,10 +1,13 @@ package com.auth0.android.myaccount +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.PublicKeyCredentials @@ -22,6 +25,8 @@ import com.auth0.android.result.Factors import com.auth0.android.result.PasskeyAuthenticationMethod import com.auth0.android.result.PasskeyEnrollmentChallenge import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.android.result.PasswordAuthenticationMethod +import com.auth0.android.result.PasswordEnrollmentChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge @@ -52,7 +57,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private val accessToken: String, private val factory: RequestFactory, private val gson: Gson -) { +) : SenderConstraining { + + private var dPoP: DPoP? = null + + private val authorizationHeader: String + get() = if (dPoP != null) "DPoP $accessToken" else "Bearer $accessToken" /** * Creates a new MyAccountAPI client instance. @@ -75,6 +85,25 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting GsonProvider.gson ) + /** + * Enable DPoP (Demonstrating Proof of Possession) for this client. + * + * When enabled, requests will include a DPoP proof header and the Authorization header + * will use the "DPoP" scheme instead of "Bearer". + * + * Example usage: + * ```kotlin + * val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) + * ``` + * + * @param context the Android context + * @return this client instance for chaining + */ + override fun useDPoP(context: Context): MyAccountAPIClient { + dPoP = DPoP(context) + return this + } + /** * Requests a challenge for enrolling a new passkey. This is the first part of the enrollment flow. @@ -82,12 +111,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * You can specify an optional user identity identifier and an optional database connection name. * If a connection name is not specified, your tenant's default directory will be used. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * ## Scopes Required * * `create:me:authentication_methods` @@ -171,20 +194,14 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting ) } } - return factory.post(url.toString(), passkeyEnrollmentAdapter) + return factory.post(url.toString(), passkeyEnrollmentAdapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * Enrolls a new passkey credential. This is the last part of the enrollment flow. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * ## Scopes Required * * `create:me:authentication_methods` @@ -239,23 +256,18 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return factory.post( url.toString(), - GsonAdapter(PasskeyAuthenticationMethod::class.java, gson) + GsonAdapter(PasskeyAuthenticationMethod::class.java, gson), + dPoP ) .addParameters(params) .addParameter(AUTHN_RESPONSE_KEY, authnResponse) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * Retrieves a detailed list of authentication methods belonging to the user. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * * ## Usage * @@ -264,6 +276,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * val apiClient = MyAccountAPIClient(auth0, accessToken) * * + * // Get all authentication methods * apiClient.getAuthenticationMethods() * .start(object : Callback, MyAccountException> { * override fun onSuccess(result: List) { @@ -274,32 +287,48 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * Log.e("MyApp", "Failed with: ${error.message}") * } * }) + * + * // Get authentication methods filtered by type + * apiClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + * .start(object : Callback, MyAccountException> { + * override fun onSuccess(result: List) { + * Log.d("MyApp", "Passkey methods: $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) * ``` * + * @param type Optional filter to retrieve only authentication methods of a specific type. + * @return A request to get the list of authentication methods. + * */ - public fun getAuthenticationMethods(): Request, MyAccountException> { - val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + @JvmOverloads + public fun getAuthenticationMethods(type: AuthenticationMethodType? = null): Request, MyAccountException> { + val url = getDomainUrlBuilder().apply { + addPathSegment(AUTHENTICATION_METHODS) + type?.let { addQueryParameter(TYPE_KEY, it.type) } + }.build() val listAdapter = object : JsonAdapter> { - override fun fromJson(reader: Reader, metadata: Map): List { + override fun fromJson( + reader: Reader, + metadata: Map + ): List { val container = gson.fromJson(reader, AuthenticationMethods::class.java) return container.authenticationMethods } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * Retrieves a single authentication method belonging to the user. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * * ## Usage * @@ -328,19 +357,13 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(AUTHENTICATION_METHODS) .addPathSegment(authenticationMethodId) .build() - return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * Updates a single authentication method belonging to the user. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * * ## Usage * @@ -361,13 +384,13 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * }) * ``` * - * @param authenticationMethodId Id of the authentication method to be retrieved + * @param authenticationMethodId Id of the authentication method to be updated * @param authenticationMethodName The friendly name of the authentication method * @param preferredAuthenticationMethod The preferred authentication method for the user. (for phone authenticators) * */ @JvmOverloads - internal fun updateAuthenticationMethodById( + public fun updateAuthenticationMethodById( authenticationMethodId: String, authenticationMethodName: String? = null, preferredAuthenticationMethod: PhoneAuthenticationMethodType? = null @@ -387,21 +410,15 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } }.asDictionary() - return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** * Deletes an existing authentication method belonging to the user. * - * ## Availability - * - * This feature is currently available in - * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). - * Please reach out to Auth0 support to get it enabled for your tenant. - * * ## Scopes Required * `delete:me:authentication_methods` * @@ -435,8 +452,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val voidAdapter = object : JsonAdapter { override fun fromJson(reader: Reader, metadata: Map): Void? = null } - return factory.delete(url.toString(), voidAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.delete(url.toString(), voidAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -472,8 +489,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.factors } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -576,9 +593,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "totp").asDictionary() val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -609,9 +626,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() // The response structure for push notification challenge is the same as TOTP (contains barcode_uri) val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -641,9 +658,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "recovery-code").asDictionary() val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val adapter = GsonAdapter(RecoveryCodeEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -684,9 +701,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf("otp_code" to otpCode, AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -724,9 +741,103 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf(AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) + } + + /** + * Starts the enrollment of a password authentication method. This is the first part of a + * two-step flow: the returned challenge carries the [com.auth0.android.result.PasswordPolicy] + * the new password must satisfy, along with the `id` and `authSession` needed to confirm the + * enrollment via [verifyPassword]. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollPassword() + * .start(object : Callback { + * override fun onSuccess(result: PasswordEnrollmentChallenge) { + * // Use result.policy to guide the user, then call verifyPassword(...) + * } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @param userIdentity Unique identifier of the current user's identity. Needed if the user logged in with a [linked account](https://auth0.com/docs/manage-users/user-accounts/user-account-linking) + * @param connection Name of the database connection where the user is stored + * @return a request that will yield a password enrollment challenge. + */ + @JvmOverloads + public fun enrollPassword( + userIdentity: String? = null, + connection: String? = null + ): Request { + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + val params = ParameterBuilder.newBuilder().apply { + set(TYPE_KEY, AuthenticationMethodType.PASSWORD.type) + userIdentity?.let { set(USER_IDENTITY_ID_KEY, it) } + connection?.let { set(CONNECTION_KEY, it) } + }.asDictionary() + return factory.post( + url.toString(), + GsonAdapter(PasswordEnrollmentChallenge::class.java, gson), + dPoP + ) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) + } + + /** + * Confirms the enrollment of a password method by providing the new password. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * val authMethodId = "from_enrollment_challenge" + * val authSession = "from_enrollment_challenge" + * val newPassword = "the_users_new_password" + * + * apiClient.verifyPassword(authMethodId, authSession, newPassword) + * .start(object : Callback { + * override fun onSuccess(result: PasswordAuthenticationMethod) { //... } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @param authenticationMethodId The ID of the method being verified (from the enrollment challenge). + * @param authSession The auth session from the enrollment challenge. + * @param newPassword The new password to set, satisfying the policy from the enrollment challenge. + * @return a request that will yield the newly verified password authentication method. + */ + public fun verifyPassword( + authenticationMethodId: String, + authSession: String, + newPassword: String + ): Request { + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authenticationMethodId) + .addPathSegment(VERIFY) + .build() + val params = mapOf(NEW_PASSWORD_KEY to newPassword, AUTH_SESSION_KEY to authSession) + return factory.post( + url.toString(), + GsonAdapter(PasswordAuthenticationMethod::class.java, gson), + dPoP + ) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } // WebAuthn methods are private. @@ -787,9 +898,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private fun buildEnrollmentRequest(params: Map): Request { val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() - return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } private fun getDomainUrlBuilder(): HttpUrl.Builder { @@ -810,6 +921,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private const val AUTHORIZATION_KEY = "Authorization" private const val LOCATION_KEY = "location" private const val AUTH_SESSION_KEY = "auth_session" + private const val NEW_PASSWORD_KEY = "new_password" private const val AUTHN_RESPONSE_KEY = "authn_response" private const val PREFERRED_AUTHENTICATION_METHOD = "preferred_authentication_method" private const val AUTHENTICATION_METHOD_NAME = "name" @@ -848,5 +960,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } } } + + init { + factory.setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt b/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt new file mode 100644 index 000000000..d76cae121 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt @@ -0,0 +1,26 @@ +package com.auth0.android.provider + +import android.app.Activity +import android.net.Uri +import androidx.browser.auth.AuthTabIntent +import com.auth0.android.authentication.AuthenticationException + +internal class AuthTabResultHandler( + private val onSuccess: (Uri?) -> Unit, + private val onFailure: (AuthenticationException) -> Unit, + private val onCancel: () -> Unit +) { + fun handle(resultCode: Int, resultUri: Uri?) { + when (resultCode) { + Activity.RESULT_OK -> onSuccess(resultUri) + AuthTabIntent.RESULT_VERIFICATION_FAILED, + AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> onFailure( + AuthenticationException( + "a0.auth_tab_verification_failed", + "Auth Tab redirect URI scheme verification failed." + ) + ) + else -> onCancel() + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt index 5f3c3f4e6..a20de764c 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -1,11 +1,13 @@ package com.auth0.android.provider -import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.VisibleForTesting +import androidx.browser.auth.AuthTabIntent import com.auth0.android.authentication.AuthenticationException import com.auth0.android.authentication.AuthenticationException.Companion.ERROR_KEY_CT_OPTIONS_NULL import com.auth0.android.authentication.AuthenticationException.Companion.ERROR_KEY_URI_NULL @@ -17,15 +19,36 @@ import com.auth0.android.provider.WebAuthProvider.resume import com.auth0.android.request.internal.CommonThreadSwitcher.Companion.getInstance import com.google.androidbrowserhelper.trusted.TwaLauncher -public open class AuthenticationActivity : Activity() { +public open class AuthenticationActivity : ComponentActivity() { private var intentLaunched = false + internal val authTabResultHandler = AuthTabResultHandler( + onSuccess = { uri -> + deliverAuthenticationResult(uri?.let { Intent().setData(it) } ?: Intent()) + finish() + }, + onFailure = { ex -> deliverAuthenticationFailure(ex) }, + onCancel = { + deliverAuthenticationResult(Intent()) + finish() + } + ) + + private val authTabLauncher: ActivityResultLauncher = + AuthTabIntent.registerActivityResultLauncher(this) { result -> + authTabResultHandler.handle(result.resultCode, result.resultUri) + } private var customTabsController: CustomTabsController? = null - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) } + @Suppress("DEPRECATION") public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + // If the Activity Result API (e.g. auth tab) already handled this result and called + // finish(), skip our legacy delivery to prevent a second delivery. + if (isFinishing) return val resultData = if (resultCode == RESULT_CANCELED) Intent() else data deliverAuthenticationResult(resultData) finish() @@ -40,13 +63,17 @@ public open class AuthenticationActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { - WebAuthProvider.onRestoreInstanceState(savedInstanceState) + WebAuthProvider.onRestoreInstanceState(savedInstanceState, this) intentLaunched = savedInstanceState.getBoolean(EXTRA_INTENT_LAUNCHED, false) } } override fun onResume() { super.onResume() + // Auth Tab results are delivered via the Activity Result API callback (onAuthTabResult) + // before onResume, which calls finish(). Without this guard, onResume would treat the + // missing intent data as a cancellation and deliver a second, spurious result. + if (isFinishing) return val authenticationIntent = intent if (!intentLaunched && authenticationIntent.extras == null) { //Activity was launched in an unexpected way @@ -113,7 +140,7 @@ public open class AuthenticationActivity : Activity() { internal open fun createCustomTabsController( context: Context, options: CustomTabsOptions ): CustomTabsController { - return CustomTabsController(context, options, TwaLauncher(context)) + return CustomTabsController(context, options, TwaLauncher(context), authTabLauncher) } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt b/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt new file mode 100644 index 000000000..f271588a3 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt @@ -0,0 +1,52 @@ +package com.auth0.android.provider + +import com.auth0.android.authentication.AuthenticationException + +/** + * Parses the result from an authorization redirect callback. + */ +internal object AuthorizeResultParser { + + sealed class CodeResult { + data class Success(val code: String, val state: String?) : CodeResult() + data class Error(val exception: AuthenticationException) : CodeResult() + object Canceled : CodeResult() + object Invalid : CodeResult() + } + + private const val KEY_CODE = "code" + private const val KEY_STATE = "state" + private const val KEY_ERROR = "error" + private const val KEY_ERROR_DESCRIPTION = "error_description" + + fun parse(result: AuthorizeResult, requestCode: Int): CodeResult { + if (!result.isValid(requestCode)) { + return CodeResult.Invalid + } + + if (result.isCanceled) { + return CodeResult.Canceled + } + + val values = CallbackHelper.getValuesFromUri(result.intentData) + if (values.isEmpty()) { + return CodeResult.Invalid + } + + val error = values[KEY_ERROR] + if (error != null) { + val description = values[KEY_ERROR_DESCRIPTION] ?: error + return CodeResult.Error(AuthenticationException(error, description)) + } + + val code = values[KEY_CODE] + ?: return CodeResult.Error( + AuthenticationException( + "access_denied", + "No authorization code was received in the callback." + ) + ) + + return CodeResult.Success(code = code, state = values[KEY_STATE]) + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/BrowserPicker.java b/auth0/src/main/java/com/auth0/android/provider/BrowserPicker.java index 11a2fc26e..8a59115fb 100644 --- a/auth0/src/main/java/com/auth0/android/provider/BrowserPicker.java +++ b/auth0/src/main/java/com/auth0/android/provider/BrowserPicker.java @@ -4,7 +4,6 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; -import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.NonNull; @@ -116,7 +115,7 @@ String getBestBrowserPackage(@NonNull PackageManager pm) { defaultBrowser = webHandler.activityInfo.packageName; } - final List availableBrowsers = pm.queryIntentActivities(browserIntent, Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PackageManager.MATCH_ALL : 0); + final List availableBrowsers = pm.queryIntentActivities(browserIntent, PackageManager.MATCH_ALL); final List regularBrowsers = new ArrayList<>(); final List customTabsBrowsers = new ArrayList<>(); final boolean isFilterEnabled = allowedPackages != null; diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java index d60c57e9f..74718df39 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java @@ -7,15 +7,18 @@ import android.net.Uri; import android.util.Log; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.browser.auth.AuthTabIntent; +import androidx.browser.auth.AuthTabSession; import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsServiceConnection; import androidx.browser.customtabs.CustomTabsSession; import com.auth0.android.authentication.AuthenticationException; import com.auth0.android.callback.RunnableTask; -import com.auth0.android.request.internal.CommonThreadSwitcher; import com.auth0.android.request.internal.ThreadSwitcher; import com.google.androidbrowserhelper.trusted.TwaLauncher; @@ -33,9 +36,12 @@ class CustomTabsController extends CustomTabsServiceConnection { private final WeakReference context; private final AtomicReference session; + private final AtomicReference authTabSession; private final CountDownLatch sessionLatch; private final String preferredPackage; private final TwaLauncher twaLauncher; + @Nullable + private final ActivityResultLauncher authTabLauncher; @NonNull private final CustomTabsOptions customTabsOptions; @@ -44,13 +50,17 @@ class CustomTabsController extends CustomTabsServiceConnection { boolean launchedAsTwa; @VisibleForTesting - CustomTabsController(@NonNull Context context, @NonNull CustomTabsOptions options, @NonNull TwaLauncher twaLauncher) { + CustomTabsController(@NonNull Context context, @NonNull CustomTabsOptions options, + @NonNull TwaLauncher twaLauncher, + @Nullable ActivityResultLauncher authTabLauncher) { this.context = new WeakReference<>(context); this.session = new AtomicReference<>(); + this.authTabSession = new AtomicReference<>(); this.sessionLatch = new CountDownLatch(1); this.customTabsOptions = options; this.preferredPackage = options.getPreferredPackage(context.getPackageManager()); - this.twaLauncher = twaLauncher; + this.twaLauncher = twaLauncher; + this.authTabLauncher = authTabLauncher; } @VisibleForTesting @@ -63,6 +73,9 @@ public void onCustomTabsServiceConnected(@NonNull ComponentName componentName, @ Log.d(TAG, "CustomTabs Service connected"); customTabsClient.warmup(0L); session.set(customTabsClient.newSession(null)); + if (customTabsOptions.isAuthTab()) { + authTabSession.set(customTabsClient.newAuthTabSession(null, Runnable::run)); + } sessionLatch.countDown(); } @@ -70,6 +83,7 @@ public void onCustomTabsServiceConnected(@NonNull ComponentName componentName, @ public void onServiceDisconnected(ComponentName componentName) { Log.d(TAG, "CustomTabs Service disconnected"); session.set(null); + authTabSession.set(null); } /** @@ -128,6 +142,8 @@ public void launchUri(@NonNull final Uri uri, final boolean launchAsTwa, ThreadS null, TwaLauncher.CCT_FALLBACK_STRATEGY ); + } else if (customTabsOptions.isAuthTab()) { + launchAsAuthTab(context, uri, threadSwitcher, failureCallback); } else { launchAsDefault(context, uri); } @@ -141,6 +157,60 @@ public void launchUri(@NonNull final Uri uri, final boolean launchAsTwa, ThreadS }); } + private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri, @NonNull ThreadSwitcher threadSwitcher, @Nullable RunnableTask failureCallback) { + if (preferredPackage == null) { + Log.d(TAG, "No compatible browser found for Auth Tab. Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + if (!CustomTabsClient.isAuthTabSupported(context, preferredPackage)) { + Log.d(TAG, "Auth Tab is not supported by " + preferredPackage + ". Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + if (authTabLauncher == null) { + Log.w(TAG, "Auth Tab launcher is not available. Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + String redirectUri = uri.getQueryParameter("redirect_uri"); + if (redirectUri == null) { + Log.e(TAG, "Could not determine redirect URI from authorize URL. This is likely a configuration error."); + if (failureCallback != null) { + AuthenticationException e = new AuthenticationException( + "a0.invalid_authorize_url", "Could not determine redirect URI from authorize URL"); + threadSwitcher.mainThread(() -> failureCallback.apply(e)); + } + return; + } + String scheme = Uri.parse(redirectUri).getScheme(); + if (scheme == null) { + Log.e(TAG, "Could not determine scheme from redirect URI: " + redirectUri + ". This is likely a configuration error."); + if (failureCallback != null) { + AuthenticationException e = new AuthenticationException( + "a0.invalid_authorize_url", "Could not determine scheme from redirect URI: " + redirectUri); + threadSwitcher.mainThread(() -> failureCallback.apply(e)); + } + return; + } + + bindService(); + boolean sessionAvailable = false; + try { + sessionAvailable = sessionLatch.await(MAX_WAIT_TIME_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + } + Log.d(TAG, "Launching URI as Auth Tab. Session available: " + sessionAvailable); + + AuthTabIntent.Builder builder = customTabsOptions.toAuthTabIntentBuilder(context); + AuthTabSession authSession = authTabSession.get(); + if (authSession != null) { + builder.setSession(authSession); + } + AuthTabIntent authTabIntent = builder.build(); + authTabIntent.launch(authTabLauncher, uri, scheme); + } + private void launchAsDefault(Context context, Uri uri) { bindService(); boolean available = false; diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 765eed204..6002ff221 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -7,11 +7,17 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; +import android.util.DisplayMetrics; +import android.util.Log; import androidx.annotation.ColorRes; +import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.browser.auth.AuthTabColorSchemeParams; +import androidx.browser.auth.AuthTabIntent; import androidx.browser.customtabs.CustomTabColorSchemeParams; +import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsIntent; import androidx.browser.customtabs.CustomTabsSession; import androidx.browser.trusted.TrustedWebActivityIntentBuilder; @@ -26,6 +32,8 @@ */ public class CustomTabsOptions implements Parcelable { + private static final String TAG = "CustomTabsOptions"; + private final boolean showTitle; @ColorRes private final int toolbarColor; @@ -34,11 +42,44 @@ public class CustomTabsOptions implements Parcelable { @Nullable private final List disabledCustomTabsPackages; - private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, @Nullable List disabledCustomTabsPackages) { + private final boolean ephemeralBrowsing; + + // Partial Custom Tabs - Bottom Sheet + private final int initialHeight; + private final int activityHeightResizeBehavior; + private final int toolbarCornerRadius; + + // Partial Custom Tabs - Side Sheet + private final int initialWidth; + private final int sideSheetBreakpoint; + + // Partial Custom Tabs - Background Interaction + private final boolean backgroundInteractionEnabled; + + private final boolean authTab; + + private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, + @Nullable List disabledCustomTabsPackages, + int initialHeight, int activityHeightResizeBehavior, int toolbarCornerRadius, + int initialWidth, int sideSheetBreakpoint, + boolean backgroundInteractionEnabled, boolean ephemeralBrowsing, + boolean authTab) { this.showTitle = showTitle; this.toolbarColor = toolbarColor; this.browserPicker = browserPicker; this.disabledCustomTabsPackages = disabledCustomTabsPackages; + this.ephemeralBrowsing = ephemeralBrowsing; + this.initialHeight = initialHeight; + this.activityHeightResizeBehavior = activityHeightResizeBehavior; + this.toolbarCornerRadius = toolbarCornerRadius; + this.initialWidth = initialWidth; + this.sideSheetBreakpoint = sideSheetBreakpoint; + this.backgroundInteractionEnabled = backgroundInteractionEnabled; + this.authTab = authTab; + } + + boolean isAuthTab() { + return authTab; } @Nullable @@ -60,6 +101,34 @@ boolean isDisabledCustomTabBrowser(@NonNull String preferredPackage) { return disabledCustomTabsPackages != null && disabledCustomTabsPackages.contains(preferredPackage); } + @NonNull + Builder toBuilder() { + Builder builder = new Builder(); + builder.showTitle = this.showTitle; + builder.toolbarColor = this.toolbarColor; + builder.browserPicker = this.browserPicker; + builder.disabledCustomTabsPackages = this.disabledCustomTabsPackages; + builder.initialHeight = this.initialHeight; + builder.activityHeightResizeBehavior = this.activityHeightResizeBehavior; + builder.toolbarCornerRadius = this.toolbarCornerRadius; + builder.initialWidth = this.initialWidth; + builder.sideSheetBreakpoint = this.sideSheetBreakpoint; + builder.backgroundInteractionEnabled = this.backgroundInteractionEnabled; + builder.ephemeralBrowsing = this.ephemeralBrowsing; + builder.authTab = this.authTab; + return builder; + } + + @NonNull + CustomTabsOptions copyWithEphemeralBrowsing() { + return toBuilder().withEphemeralBrowsing().build(); + } + + @NonNull + CustomTabsOptions copyWithAuthTab() { + return toBuilder().withAuthTab().build(); + } + /** * Create a new CustomTabsOptions.Builder instance. * @@ -82,15 +151,61 @@ Intent toIntent(@NonNull Context context, @Nullable CustomTabsSession session) { final CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(session) .setShowTitle(showTitle) .setShareState(CustomTabsIntent.SHARE_STATE_OFF); + + if (ephemeralBrowsing) { + if (preferredPackage != null + && CustomTabsClient.isEphemeralBrowsingSupported(context, preferredPackage)) { + builder.setEphemeralBrowsingEnabled(true); + } else { + Log.w(TAG, "Ephemeral browsing was requested but is not supported by the " + + "current browser (" + preferredPackage + "). " + + "Falling back to a regular Custom Tab."); + } + } + if (toolbarColor > 0) { //Resource exists final CustomTabColorSchemeParams.Builder colorBuilder = new CustomTabColorSchemeParams.Builder() .setToolbarColor(ContextCompat.getColor(context, toolbarColor)); builder.setDefaultColorSchemeParams(colorBuilder.build()); } + + // Partial Custom Tabs - Bottom Sheet + if (initialHeight > 0) { + builder.setInitialActivityHeightPx(dpToPx(context, initialHeight), activityHeightResizeBehavior); + } + if (toolbarCornerRadius > 0) { + builder.setToolbarCornerRadiusDp(toolbarCornerRadius); + } + + // Partial Custom Tabs - Side Sheet + if (initialWidth > 0) { + builder.setInitialActivityWidthPx(dpToPx(context, initialWidth)); + } + if (sideSheetBreakpoint > 0) { + builder.setActivitySideSheetBreakpointDp(sideSheetBreakpoint); + } + + // Partial Custom Tabs - Background Interaction + if (backgroundInteractionEnabled) { + builder.setBackgroundInteractionEnabled(true); + } + return builder.build().intent; } + @SuppressLint("ResourceType") + AuthTabIntent.Builder toAuthTabIntentBuilder(@NonNull Context context) { + AuthTabIntent.Builder builder = new AuthTabIntent.Builder(); + if (toolbarColor > 0) { + final AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(context, toolbarColor)) + .build(); + builder.setDefaultColorSchemeParams(params); + } + return builder; + } + @SuppressLint("ResourceType") TrustedWebActivityIntentBuilder toTwaIntentBuilder(@NonNull Context context, @NonNull Uri uri) { TrustedWebActivityIntentBuilder builder = new TrustedWebActivityIntentBuilder(uri); @@ -108,6 +223,14 @@ protected CustomTabsOptions(@NonNull Parcel in) { toolbarColor = in.readInt(); browserPicker = in.readParcelable(BrowserPicker.class.getClassLoader()); disabledCustomTabsPackages = in.createStringArrayList(); + ephemeralBrowsing = in.readByte() != 0; + initialHeight = in.readInt(); + activityHeightResizeBehavior = in.readInt(); + toolbarCornerRadius = in.readInt(); + initialWidth = in.readInt(); + sideSheetBreakpoint = in.readInt(); + backgroundInteractionEnabled = in.readByte() != 0; + authTab = in.readByte() != 0; } @Override @@ -116,6 +239,14 @@ public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeInt(toolbarColor); dest.writeParcelable(browserPicker, flags); dest.writeStringList(disabledCustomTabsPackages); + dest.writeByte((byte) (ephemeralBrowsing ? 1 : 0)); + dest.writeInt(initialHeight); + dest.writeInt(activityHeightResizeBehavior); + dest.writeInt(toolbarCornerRadius); + dest.writeInt(initialWidth); + dest.writeInt(sideSheetBreakpoint); + dest.writeByte((byte) (backgroundInteractionEnabled ? 1 : 0)); + dest.writeByte((byte) (authTab ? 1 : 0)); } @Override @@ -147,11 +278,29 @@ public static class Builder { @Nullable private List disabledCustomTabsPackages; + private boolean ephemeralBrowsing; + private boolean authTab; + + private int initialHeight; + private int activityHeightResizeBehavior; + private int toolbarCornerRadius; + private int initialWidth; + private int sideSheetBreakpoint; + private boolean backgroundInteractionEnabled; + Builder() { this.showTitle = false; this.toolbarColor = 0; this.browserPicker = BrowserPicker.newBuilder().build(); this.disabledCustomTabsPackages = null; + this.ephemeralBrowsing = false; + this.authTab = false; + this.initialHeight = 0; + this.activityHeightResizeBehavior = CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT; + this.toolbarCornerRadius = 0; + this.initialWidth = 0; + this.sideSheetBreakpoint = 0; + this.backgroundInteractionEnabled = false; } /** @@ -212,6 +361,128 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac return this; } + Builder withEphemeralBrowsing() { + this.ephemeralBrowsing = true; + return this; + } + + Builder withAuthTab() { + this.authTab = true; + return this; + } + + /** + * Sets the initial height for the Custom Tab to display as a bottom sheet. + * When set, the Custom Tab will appear as a bottom sheet instead of full screen. + * Pass the size in dp; it will be converted to pixels internally. + * The minimum height enforced by Chrome is 50% of the screen; values below this are auto-adjusted. + * Falls back to full screen on browsers that don't support Partial Custom Tabs (requires Chrome 107+). + * By default, the bottom sheet is resizable by the user. Use {@link #withResizable(boolean)} + * to lock the height. + * + * @param height the initial bottom sheet height in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withInitialHeight(@Dimension(unit = Dimension.DP) int height) { + this.initialHeight = height; + return this; + } + + /** + * Sets whether the user can resize the Partial Custom Tab by dragging. + * For bottom sheets, this controls whether the user can drag the toolbar handle to + * expand or collapse the sheet. For side sheets, this controls whether the sheet + * can be resized. By default, the Partial Custom Tab is resizable. + * Pass {@code false} to lock the size. Only takes effect when + * {@link #withInitialHeight(int)} is also set. + * + * @param resizable whether the Partial Custom Tab should be resizable. + * @return this same builder instance. + */ + @NonNull + public Builder withResizable(boolean resizable) { + this.activityHeightResizeBehavior = resizable + ? CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE + : CustomTabsIntent.ACTIVITY_HEIGHT_FIXED; + return this; + } + + /** + * Sets the toolbar's top corner radii in dp. Only takes effect when the Custom Tab is + * displayed as a bottom sheet (i.e., when {@link #withInitialHeight(int)} is also set). + * Pass the size in dp. The underlying + * {@link CustomTabsIntent.Builder#setToolbarCornerRadiusDp(int)} only accepts values in + * the range {@code 0}–{@code 16} (inclusive); to avoid a runtime crash, values + * outside that range are clamped: negative values are treated as {@code 0} and values + * greater than {@code 16} are capped to {@code 16}. + * + * @param cornerRadius the toolbar's top corner radius in dp. Clamped to {@code [0, 16]}. + * @return this same builder instance. + */ + @NonNull + public Builder withToolbarCornerRadius(@Dimension(unit = Dimension.DP) int cornerRadius) { + if (cornerRadius < 0) { + cornerRadius = 0; + } + if (cornerRadius > 16) { + cornerRadius = 16; + } + this.toolbarCornerRadius = cornerRadius; + return this; + } + + /** + * Sets the initial width for the Custom Tab to display as a side sheet on larger screens. + * The Custom Tab will behave as a side sheet only if the screen's width is bigger than + * the breakpoint value set by {@link #withSideSheetBreakpoint(int)}. If no breakpoint is + * explicitly set, the browser's default breakpoint (typically 840dp in Chrome) is used, + * so smaller-width devices will continue to render as a bottom sheet or full screen + * rather than as a side sheet. + * Pass the size in dp; it will be converted to pixels internally. + * Falls back to bottom sheet or full screen on unsupported browsers. + * + * @param width the initial side sheet width in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withInitialWidth(@Dimension(unit = Dimension.DP) int width) { + this.initialWidth = width; + return this; + } + + /** + * Sets the breakpoint in dp to switch between bottom sheet and side sheet mode. + * If the screen's width is bigger than this value, the Custom Tab will behave as a side sheet; + * otherwise it will behave as a bottom sheet. + *

+ * When this method is not called (or the value is left at the default {@code 0}), the + * breakpoint is not overridden and the browser's built-in default (typically + * {@code 840dp} in Chrome) is applied. This means devices with a screen width smaller + * than the browser default will still render as a bottom sheet, not a side sheet. + * + * @param breakpoint the breakpoint in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withSideSheetBreakpoint(@Dimension(unit = Dimension.DP) int breakpoint) { + this.sideSheetBreakpoint = breakpoint; + return this; + } + + /** + * Enables or disables interaction with the background app when a Partial Custom Tab is displayed. + * By default, background interaction is disabled. + * + * @param enabled whether to enable interaction with the app behind the partial tab. + * @return this same builder instance. + */ + @NonNull + public Builder withBackgroundInteractionEnabled(boolean enabled) { + this.backgroundInteractionEnabled = enabled; + return this; + } + /** * Create a new CustomTabsOptions instance with the customization settings. * @@ -219,8 +490,16 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac */ @NonNull public CustomTabsOptions build() { - return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages); + return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages, + initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, + initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, ephemeralBrowsing, + authTab); } } + private int dpToPx(@NonNull Context context, int dp) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + return Math.round(dp * metrics.density); + } + } diff --git a/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt new file mode 100644 index 000000000..e1081e491 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt @@ -0,0 +1,53 @@ +package com.auth0.android.provider + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback + +/** + * Wraps a user-provided callback and observes the Activity/Fragment lifecycle. + * When the host is destroyed (e.g. config change), [delegateCallback] is set to null so + * the destroyed Activity is no longer referenced by the SDK. + * + * If a result arrives after [delegateCallback] has been cleared, the [onDetached] lambda + * is invoked to cache the result for later recovery via resumePending*Result(). + * + * @param S the success type (Credentials for login, Void? for logout) + * @param delegateCallback the user's original callback + * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe + * @param onDetached called when a result arrives but the callback is already detached + */ +internal class LifecycleAwareCallback( + @Volatile private var delegateCallback: Callback?, + lifecycleOwner: LifecycleOwner, + private val onDetached: (success: S?, error: AuthenticationException?) -> Unit, +) : Callback, DefaultLifecycleObserver { + + init { + lifecycleOwner.lifecycle.addObserver(this) + } + + override fun onSuccess(result: S) { + val cb = delegateCallback + if (cb != null) { + cb.onSuccess(result) + } else { + onDetached(result, null) + } + } + + override fun onFailure(error: AuthenticationException) { + val cb = delegateCallback + if (cb != null) { + cb.onFailure(error) + } else { + onDetached(null, error) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + delegateCallback = null + owner.lifecycle.removeObserver(this) + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt index acc2b2864..718d3a788 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -29,6 +29,7 @@ internal class OAuthManager( @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val dPoP: DPoP? = null ) : ResumableManager() { + private val parameters: MutableMap private val headers: MutableMap private val ctOptions: CustomTabsOptions @@ -211,7 +212,8 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, - customAuthorizeUrl = this.customAuthorizeUrl + customAuthorizeUrl = this.customAuthorizeUrl, + dPoPEnabled = dPoP != null ) } @@ -387,14 +389,21 @@ internal class OAuthManager( internal fun OAuthManager.Companion.fromState( state: OAuthManagerState, - callback: Callback + callback: Callback, + context: Context ): OAuthManager { + // Enable DPoP on the restored PKCE's AuthenticationAPIClient so that + // the token exchange request includes the DPoP proof after process restore. + if (state.dPoPEnabled && state.pkce != null) { + state.pkce.apiClient.useDPoP(context) + } return OAuthManager( account = state.auth0, ctOptions = state.ctOptions, parameters = state.parameters, callback = callback, - customAuthorizeUrl = state.customAuthorizeUrl + customAuthorizeUrl = state.customAuthorizeUrl, + dPoP = if (state.dPoPEnabled ) DPoP(context) else null ).apply { setHeaders( state.headers diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index ab677af6c..06d0c6b0b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -6,7 +6,6 @@ import android.util.Base64 import androidx.core.os.ParcelCompat import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.dpop.DPoP import com.auth0.android.request.internal.GsonProvider import com.google.gson.Gson @@ -20,7 +19,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean = false ) { private class OAuthManagerJson( @@ -37,7 +36,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean ) fun serializeToJson( @@ -62,7 +61,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, - dPoP = this.dPoP + dPoPEnabled = this.dPoPEnabled ) return gson.toJson(json) } finally { @@ -112,7 +111,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, - dPoP = oauthManagerJson.dPoP + dPoPEnabled = oauthManagerJson.dPoPEnabled ) } finally { parcel.recycle() diff --git a/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt new file mode 100644 index 000000000..190e81502 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt @@ -0,0 +1,88 @@ +package com.auth0.android.provider + +import android.content.Context +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.AuthorizationCode + +/** + * Manager for handling PAR (Pushed Authorization Request) code-only flows. + * This manager handles opening the authorize URL with a request_uri and + * returns the authorization code to the caller for BFF token exchange. + */ +internal class PARCodeManager( + private val account: Auth0, + private val callback: Callback, + private val requestUri: String, + private val sessionTransferToken: String? = null, + private val ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() +) : ResumableManager() { + + private var requestCode = 0 + + fun startAuthentication(context: Context, requestCode: Int) { + this.requestCode = requestCode + val additionalParams = buildMap { + sessionTransferToken?.let { put("session_transfer_token", it) } + } + val uri = PARUtils.buildAuthorizeUri(account, requestUri, additionalParams) + AuthenticationActivity.authenticateUsingBrowser(context, uri, false, ctOptions) + } + + override fun resume(result: AuthorizeResult): Boolean { + return when (val parsed = AuthorizeResultParser.parse(result, requestCode)) { + is AuthorizeResultParser.CodeResult.Success -> { + callback.onSuccess(AuthorizationCode(parsed.code, parsed.state)) + true + } + is AuthorizeResultParser.CodeResult.Error -> { + callback.onFailure(parsed.exception) + true + } + is AuthorizeResultParser.CodeResult.Canceled -> { + callback.onFailure( + AuthenticationException( + AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, + "The user closed the browser app and the authentication was canceled." + ) + ) + true + } + AuthorizeResultParser.CodeResult.Invalid -> false + } + } + + override fun failure(exception: AuthenticationException) { + callback.onFailure(exception) + } + + internal fun toState(): PARCodeManagerState { + return PARCodeManagerState( + auth0 = account, + requestCode = requestCode, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptions + ) + } + + internal companion object { + private val TAG = PARCodeManager::class.java.simpleName + + fun fromState( + state: PARCodeManagerState, + callback: Callback + ): PARCodeManager { + val manager = PARCodeManager( + account = state.auth0, + callback = callback, + requestUri = state.requestUri, + sessionTransferToken = state.sessionTransferToken, + ctOptions = state.ctOptions + ) + manager.requestCode = state.requestCode + return manager + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt new file mode 100644 index 000000000..a1ce7c4af --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt @@ -0,0 +1,87 @@ +package com.auth0.android.provider + +import android.os.Parcel +import android.os.Parcelable +import android.util.Base64 +import androidx.core.os.ParcelCompat +import com.auth0.android.Auth0 +import com.auth0.android.request.internal.GsonProvider +import com.google.gson.Gson + +internal data class PARCodeManagerState( + val auth0: Auth0, + val requestCode: Int, + val requestUri: String, + val sessionTransferToken: String?, + val ctOptions: CustomTabsOptions +) { + + private class PARCodeManagerJson( + val auth0ClientId: String, + val auth0DomainUrl: String, + val auth0ConfigurationUrl: String?, + val requestCode: Int, + val requestUri: String, + val sessionTransferToken: String?, + val ctOptions: String + ) + + fun serializeToJson(gson: Gson = GsonProvider.gson): String { + val parcel = Parcel.obtain() + try { + parcel.writeParcelable(ctOptions, Parcelable.PARCELABLE_WRITE_RETURN_VALUE) + val ctOptionsEncoded = Base64.encodeToString(parcel.marshall(), Base64.DEFAULT) + + val json = PARCodeManagerJson( + auth0ClientId = auth0.clientId, + auth0DomainUrl = auth0.domain, + auth0ConfigurationUrl = auth0.configurationDomain, + requestCode = requestCode, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptionsEncoded + ) + return gson.toJson(json) + } finally { + parcel.recycle() + } + } + + companion object { + fun deserializeState( + json: String, + gson: Gson = GsonProvider.gson + ): PARCodeManagerState { + val parcel = Parcel.obtain() + try { + val parsed = gson.fromJson(json, PARCodeManagerJson::class.java) + + val decodedCtOptionsBytes = Base64.decode(parsed.ctOptions, Base64.DEFAULT) + parcel.unmarshall(decodedCtOptionsBytes, 0, decodedCtOptionsBytes.size) + parcel.setDataPosition(0) + + val customTabsOptions = ParcelCompat.readParcelable( + parcel, + CustomTabsOptions::class.java.classLoader, + CustomTabsOptions::class.java + ) ?: error("Couldn't deserialize CustomTabsOptions from Parcel") + + val auth0 = Auth0.getInstance( + clientId = parsed.auth0ClientId, + domain = parsed.auth0DomainUrl, + configurationDomain = parsed.auth0ConfigurationUrl + ) + + return PARCodeManagerState( + auth0 = auth0, + requestCode = parsed.requestCode, + requestUri = parsed.requestUri, + sessionTransferToken = parsed.sessionTransferToken, + ctOptions = customTabsOptions + ) + } finally { + parcel.recycle() + } + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt b/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt new file mode 100644 index 000000000..c2bc48234 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt @@ -0,0 +1,40 @@ +package com.auth0.android.provider + +import android.net.Uri +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import androidx.core.net.toUri + +/** + * Shared utilities for PAR (Pushed Authorization Request) flows. + */ +internal object PARUtils { + + internal const val REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:" + + /** + * Validates that the request_uri conforms to the expected format. + * @return true if valid, false otherwise. + */ + fun isValidRequestUri(requestUri: String): Boolean { + return requestUri.startsWith(REQUEST_URI_PREFIX) + } + + /** + * Builds a minimal /authorize URI for PAR flows containing only client_id and request_uri, + * plus any additional query parameters. + */ + fun buildAuthorizeUri( + account: Auth0, + requestUri: String, + additionalParameters: Map = emptyMap() + ): Uri { + val builder = account.authorizeUrl.toUri().buildUpon() + .appendQueryParameter("client_id", account.clientId) + .appendQueryParameter("request_uri", requestUri) + for ((key, value) in additionalParameters) { + builder.appendQueryParameter(key, value) + } + return builder.build() + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PasskeyAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/PasskeyAuthProvider.kt deleted file mode 100644 index aee248929..000000000 --- a/auth0/src/main/java/com/auth0/android/provider/PasskeyAuthProvider.kt +++ /dev/null @@ -1,234 +0,0 @@ -package com.auth0.android.provider - -import android.content.Context -import android.os.Build -import android.util.Log -import androidx.credentials.CredentialManager -import com.auth0.android.Auth0 -import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.authentication.AuthenticationException -import com.auth0.android.authentication.ParameterBuilder -import com.auth0.android.callback.Callback -import com.auth0.android.request.UserData -import com.auth0.android.result.Credentials -import java.util.concurrent.Executor -import java.util.concurrent.Executors - -/** - * Passkey authentication provider - */ - -@Deprecated( - """PasskeyAuthProvider is deprecated and will be removed in the next major version of the SDK. - Use API's in [AuthenticationAPIClient] directly to support sign-in/signup with passkeys.""", - level = DeprecationLevel.WARNING -) -public object PasskeyAuthProvider { - - private val TAG = PasskeyManager::class.simpleName - - /** - * Initialize the PasskeyAuthProvider instance for signing up a user . Additional settings can be configured in the - * SignupBuilder. - * - * @param auth0 [Auth0] instance to be used for authentication - * @return a new builder instance to customize - */ - @JvmStatic - public fun signUp(auth0: Auth0): SignupBuilder { - return SignupBuilder(auth0) - } - - /** - * Initialize the PasskeyAuthProvider instance for signing in a user. Additional settings can be configured in the - * SignInBuilder - * - * @param auth0 [Auth0] instance to be used for authentication - * @return a new builder instance to customize - */ - @JvmStatic - public fun signIn(auth0: Auth0): SignInBuilder { - return SignInBuilder(auth0) - } - - - public class SignInBuilder internal constructor(private val auth0: Auth0) { - private val parameters: MutableMap = mutableMapOf() - - /** - * Specify the scope for this request. - * - * @param scope to request - * @return the current builder instance - */ - public fun setScope(scope: String): SignInBuilder = apply { - parameters[ParameterBuilder.SCOPE_KEY] = scope - } - - /** - * Specify the custom audience for this request. - * - * @param audience to use in this request - * @return the current builder instance - */ - public fun setAudience(audience: String): SignInBuilder = apply { - parameters[ParameterBuilder.AUDIENCE_KEY] = audience - } - - /** - * Specify the realm for this request - * - * @param realm to use in this request - * @return the current builder instance - */ - public fun setRealm(realm: String): SignInBuilder = apply { - parameters[ParameterBuilder.REALM_KEY] = realm - } - - /** - * Request user authentication using passkey. The result will be received in the callback. - * - * @param context context to run the authentication - * @param callback to receive the result - * @param executor optional executor to run the public key credential response creation - */ - public fun start( - context: Context, - callback: Callback, - executor: Executor = Executors.newSingleThreadExecutor() - ) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - Log.w(TAG, "Requires Android 9 or higher to use passkey authentication ") - val ex = AuthenticationException( - "Requires Android 9 or higher" - ) - callback.onFailure(ex) - return - } - val passkeyManager = PasskeyManager( - AuthenticationAPIClient(auth0), CredentialManager.create(context) - ) - passkeyManager.signin( - context, parameters[ParameterBuilder.REALM_KEY], parameters, callback, executor - ) - } - } - - - public class SignupBuilder internal constructor(private val auth0: Auth0) { - private var username: String? = null - private var email: String? = null - private var name: String? = null - private var phoneNumber: String? = null - - private val parameters: MutableMap = mutableMapOf() - - /** - * Specify the realm for this request - * - * @param realm to use in this request - * @return the current builder instance - */ - public fun setRealm(realm: String): SignupBuilder = apply { - parameters[ParameterBuilder.REALM_KEY] = realm - } - - /** - * Specify the email for the user. - * Email can be optional,required or forbidden depending on the attribute configuration for the database - * - * @param email to be set - * @return the current builder instance - */ - public fun setEmail(email: String): SignupBuilder = apply { - this.email = email - } - - /** - * Specify the username for the user. - * Username can be optional,required or forbidden depending on the attribute configuration for the database - * - * @param username to be set - * @return the current builder instance - */ - public fun setUserName(username: String): SignupBuilder = apply { - this.username = username - } - - /** - * Specify the name for the user. - * Name can be optional,required or forbidden depending on the attribute configuration for the database - * - * @param name to be set - * @return the current builder instance - */ - public fun setName(name: String): SignupBuilder = apply { - this.name = name - } - - /** - * Specify the phone number for the user - * Phone number can be optional,required or forbidden depending on the attribute configuration for the database - * - * @param number to be set - * @return the current builder instance - */ - public fun setPhoneNumber(number: String): SignupBuilder = apply { - this.phoneNumber = number - } - - /** - * Specify the scope for this request. - * - * @param scope to request - * @return the current builder instance - */ - public fun setScope(scope: String): SignupBuilder = apply { - parameters[ParameterBuilder.SCOPE_KEY] = scope - } - - /** - * Specify the custom audience for this request. - * - * @param audience to use in this request - * @return the current builder instance - */ - public fun setAudience(audience: String): SignupBuilder = apply { - parameters[ParameterBuilder.AUDIENCE_KEY] = audience - } - - /** - * Request user signup and authentication using passkey. The result will be received in the callback. - * - * @param context context to run the authentication - * @param callback to receive the result - * @param executor optional executor to run the public key credential response creation - */ - public fun start( - context: Context, - callback: Callback, - executor: Executor = Executors.newSingleThreadExecutor() - ) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - Log.w(TAG, "Requires Android 9 or higher to use passkey authentication ") - val ex = AuthenticationException( - "Requires Android 9 or higher" - ) - callback.onFailure(ex) - return - } - val passkeyManager = PasskeyManager( - AuthenticationAPIClient(auth0), CredentialManager.create(context) - ) - val userData = UserData(email, phoneNumber, username, name) - passkeyManager.signup( - context, - userData, - parameters[ParameterBuilder.REALM_KEY], - parameters, - callback, - executor - ) - } - } -} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/provider/PasskeyManager.kt b/auth0/src/main/java/com/auth0/android/provider/PasskeyManager.kt deleted file mode 100644 index a6fc2fe99..000000000 --- a/auth0/src/main/java/com/auth0/android/provider/PasskeyManager.kt +++ /dev/null @@ -1,245 +0,0 @@ -package com.auth0.android.provider - -import android.annotation.SuppressLint -import android.content.Context -import android.os.Build -import android.os.CancellationSignal -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.credentials.CreateCredentialResponse -import androidx.credentials.CreatePublicKeyCredentialRequest -import androidx.credentials.CreatePublicKeyCredentialResponse -import androidx.credentials.CredentialManager -import androidx.credentials.CredentialManagerCallback -import androidx.credentials.GetCredentialRequest -import androidx.credentials.GetCredentialResponse -import androidx.credentials.GetPublicKeyCredentialOption -import androidx.credentials.PublicKeyCredential -import androidx.credentials.exceptions.CreateCredentialCancellationException -import androidx.credentials.exceptions.CreateCredentialException -import androidx.credentials.exceptions.CreateCredentialInterruptedException -import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException -import androidx.credentials.exceptions.GetCredentialCancellationException -import androidx.credentials.exceptions.GetCredentialException -import androidx.credentials.exceptions.GetCredentialInterruptedException -import androidx.credentials.exceptions.GetCredentialUnsupportedException -import androidx.credentials.exceptions.NoCredentialException -import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.authentication.AuthenticationException -import com.auth0.android.callback.Callback -import com.auth0.android.request.PublicKeyCredentials -import com.auth0.android.request.UserData -import com.auth0.android.result.Credentials -import com.auth0.android.result.PasskeyChallenge -import com.auth0.android.result.PasskeyRegistrationChallenge -import com.google.gson.Gson -import java.util.concurrent.Executor -import java.util.concurrent.Executors - - -internal class PasskeyManager( - private val authenticationAPIClient: AuthenticationAPIClient, - private val credentialManager: CredentialManager -) { - - private val TAG = PasskeyManager::class.simpleName - - @RequiresApi(api = Build.VERSION_CODES.P) - @SuppressLint("PublicKeyCredential") - fun signup( - context: Context, - userData: UserData, - realm: String?, - parameters: Map, - callback: Callback, - executor: Executor = Executors.newSingleThreadExecutor() - ) { - authenticationAPIClient.signupWithPasskey(userData, realm) - .addParameters(parameters) - .start(object : Callback { - override fun onSuccess(result: PasskeyRegistrationChallenge) { - val pasKeyRegistrationResponse = result - val request = CreatePublicKeyCredentialRequest( - Gson().toJson( - pasKeyRegistrationResponse.authParamsPublicKey - ) - ) - var response: CreatePublicKeyCredentialResponse? - - credentialManager.createCredentialAsync(context, - request, - CancellationSignal(), - executor, - object : - CredentialManagerCallback { - - override fun onError(e: CreateCredentialException) { - Log.w(TAG, "Error while creating passkey") - callback.onFailure(handleCreationFailure(e)) - } - - override fun onResult(result: CreateCredentialResponse) { - - response = result as CreatePublicKeyCredentialResponse - val authRequest = Gson().fromJson( - response?.registrationResponseJson, - PublicKeyCredentials::class.java - ) - - authenticationAPIClient.signinWithPasskey( - pasKeyRegistrationResponse.authSession, - authRequest, - realm - ) - .validateClaims() - .addParameters(parameters) - .start(callback) - } - }) - - } - - override fun onFailure(error: AuthenticationException) { - callback.onFailure(error) - } - }) - - } - - - @RequiresApi(api = Build.VERSION_CODES.P) - fun signin( - context: Context, - realm: String?, - parameters: Map, - callback: Callback, - executor: Executor = Executors.newSingleThreadExecutor() - ) { - authenticationAPIClient.passkeyChallenge(realm) - .start(object : Callback { - override fun onSuccess(result: PasskeyChallenge) { - val passkeyChallengeResponse = result - val request = - GetPublicKeyCredentialOption(Gson().toJson(passkeyChallengeResponse.authParamsPublicKey)) - val getCredRequest = GetCredentialRequest( - listOf(request) - ) - credentialManager.getCredentialAsync(context, - getCredRequest, - CancellationSignal(), - executor, - object : - CredentialManagerCallback { - override fun onError(e: GetCredentialException) { - Log.w(TAG, "Error while fetching public key credential") - callback.onFailure(handleGetCredentialFailure(e)) - } - - override fun onResult(result: GetCredentialResponse) { - when (val credential = result.credential) { - is PublicKeyCredential -> { - val authRequest = Gson().fromJson( - credential.authenticationResponseJson, - PublicKeyCredentials::class.java - ) - authenticationAPIClient.signinWithPasskey( - passkeyChallengeResponse.authSession, - authRequest, - realm - ) - .validateClaims() - .addParameters(parameters) - .start(callback) - } - - else -> { - Log.w( - TAG, - "Received unrecognized credential type ${credential.type}.This shouldn't happen" - ) - callback.onFailure(AuthenticationException("Received unrecognized credential type ${credential.type}")) - } - } - } - }) - - } - - override fun onFailure(error: AuthenticationException) { - callback.onFailure(error) - } - }) - - } - - private fun handleCreationFailure(exception: CreateCredentialException): AuthenticationException { - return when (exception) { - - is CreateCredentialCancellationException -> { - AuthenticationException( - AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, - "The user cancelled passkey authentication operation." - ) - } - - is CreateCredentialInterruptedException -> { - AuthenticationException( - "Passkey authentication was interrupted. Please retry the call." - ) - } - - is CreateCredentialProviderConfigurationException -> { - AuthenticationException( - "Provider configuration dependency is missing. Ensure credentials-play-services-auth dependency is added." - ) - } - - else -> { - Log.w(TAG, "Unexpected exception type ${exception::class.java.name}") - AuthenticationException( - "An error occurred when trying to authenticate with passkey" - ) - } - } - } - - private fun handleGetCredentialFailure(exception: GetCredentialException): AuthenticationException { - - return when (exception) { - is GetCredentialCancellationException -> { - AuthenticationException( - AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, - "The user cancelled passkey authentication operation." - ) - } - - is GetCredentialInterruptedException -> { - AuthenticationException( - "Passkey authentication was interrupted. Please retry the call." - ) - } - - is GetCredentialUnsupportedException -> { - AuthenticationException( - "Credential manager is unsupported. Please update the device." - ) - } - - - is NoCredentialException -> { - AuthenticationException( - "No viable credential is available for the user" - ) - } - - - else -> { - Log.w(TAG, "Unexpected exception type ${exception::class.java.name}") - AuthenticationException( - "An error occurred when trying to authenticate with passkey" - ) - } - } - } - -} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/provider/SignatureVerifier.java b/auth0/src/main/java/com/auth0/android/provider/SignatureVerifier.java index d27e5a62e..fe5bf08d4 100644 --- a/auth0/src/main/java/com/auth0/android/provider/SignatureVerifier.java +++ b/auth0/src/main/java/com/auth0/android/provider/SignatureVerifier.java @@ -57,6 +57,10 @@ static void forAsymmetricAlgorithm(@Nullable final String keyId, @NonNull Authen @Override public void onSuccess(@Nullable Map result) { PublicKey publicKey = result.get(keyId); + if (publicKey == null) { + callback.onFailure(new PublicKeyNotFoundException(keyId)); + return; + } try { callback.onSuccess(new AsymmetricSignatureVerifier(publicKey)); } catch (InvalidKeyException e) { diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 955eec9c5..75e379c23 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -6,17 +6,22 @@ import android.net.Uri import android.os.Bundle import android.util.Log import androidx.annotation.VisibleForTesting +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import com.auth0.android.Auth0 +import com.auth0.android.annotation.ExperimentalAuth0Api import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.SenderConstraining +import com.auth0.android.result.AuthorizationCode import com.auth0.android.result.Credentials import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.util.Locale import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -27,34 +32,113 @@ import kotlin.coroutines.resumeWithException * * It uses an external browser by sending the [android.content.Intent.ACTION_VIEW] intent. */ -public object WebAuthProvider : SenderConstraining { +public object WebAuthProvider { private val TAG: String? = WebAuthProvider::class.simpleName private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state" - private var dPoP : DPoP? = null + private const val KEY_BUNDLE_PAR_MANAGER_STATE = "par_manager_state" + private const val AUTH_REQUEST_CODE = 110 private val callbacks = CopyOnWriteArraySet>() + private val parCallbacks = + CopyOnWriteArraySet>() @JvmStatic @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var managerInstance: ResumableManager? = null private set + /** + * Represents a pending authentication or logout result that arrived while + * the original callback was no longer reachable (e.g. Activity destroyed + * during a configuration change). + */ + private sealed class PendingResult { + data class Success(val result: S) : PendingResult() + data class Failure(val error: AuthenticationException) : PendingResult() + } + + private val pendingLoginResult = AtomicReference?>(null) + + private val pendingLogoutResult = AtomicReference?>(null) + + /** + * Registers login and logout callbacks for the duration of the given + * [lifecycleOwner]'s lifetime. Call this once in `onCreate()` — it covers both recovery + * scenarios automatically: + * + * - **Process death**: callbacks are registered immediately so that if the process + * was killed while the browser was open, results are delivered when the Activity is + * restored. Callbacks are automatically unregistered when [lifecycleOwner] is destroyed, + * so there is no need to call [removeCallback] manually. + * - **Configuration change** (rotation, locale, dark mode): any login or logout result + * that arrived while the Activity was being recreated is delivered on the next `onResume`. + * + * Both callbacks are required to ensure results are not lost during configuration changes + * (e.g. user logs out to switch accounts and rotates during the logout flow). + * + * ```kotlin + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * WebAuthProvider.registerCallbacks(this, loginCallback = callback, logoutCallback = voidCallback) + * } + * ``` + * + * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe + * @param loginCallback receives login results (both direct delivery and recovered results) + * @param logoutCallback receives logout results recovered after a configuration change + */ + @JvmStatic + public fun registerCallbacks( + lifecycleOwner: LifecycleOwner, + loginCallback: Callback, + logoutCallback: Callback, + ) { + // Process-death recovery: register immediately so result is routed here on restore + callbacks += loginCallback + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + // Config-change recovery: deliver any result cached while Activity was recreating + pendingLoginResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> loginCallback.onSuccess(pending.result) + is PendingResult.Failure -> loginCallback.onFailure(pending.error) + } + resetManagerInstance() + } + pendingLogoutResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> logoutCallback.onSuccess(pending.result) + is PendingResult.Failure -> logoutCallback.onFailure(pending.error) + } + resetManagerInstance() + } + } + + override fun onDestroy(owner: LifecycleOwner) { + callbacks -= loginCallback + owner.lifecycle.removeObserver(this) + } + }) + } + + @Deprecated( + message = "Use registerCallbacks() instead — it registers the callback and auto-removes it when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = logoutCallback)") + ) @JvmStatic public fun addCallback(callback: Callback) { callbacks += callback } + @Deprecated( + message = "Use registerCallbacks() instead — it auto-removes the callback when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = logoutCallback)") + ) @JvmStatic public fun removeCallback(callback: Callback) { callbacks -= callback } - // Public methods - public override fun useDPoP(context: Context): WebAuthProvider { - dPoP = DPoP(context) - return this - } - /** * Initialize the WebAuthProvider instance for logging out the user using an account. Additional settings can be configured * in the LogoutBuilder, like changing the scheme of the return to URL. @@ -79,6 +163,22 @@ public object WebAuthProvider : SenderConstraining { return Builder(account) } + /** + * Initialize the WebAuthProvider instance for request_uri based authorization flows (PAR). + * Use this when your backend (BFF) has already called the /oauth/par endpoint and you need to + * complete the authorization by opening /authorize with the request_uri. + * + * The SDK only handles opening the browser and returning the authorization code. + * Token exchange must be performed by your backend server which holds the client_secret. + * + * @param account to use for authentication + * @return a new PARBuilder instance to customize. + */ + @JvmStatic + public fun authorizeWithRequestUri(account: Auth0): PARBuilder { + return PARBuilder(account) + } + /** * Finishes the authentication or log out flow by passing the data received in the activity's onNewIntent() callback. * The final result will be delivered to the callback specified when calling start(). @@ -116,25 +216,56 @@ public object WebAuthProvider : SenderConstraining { if (manager is OAuthManager) { val managerState = manager.toState() bundle.putString(KEY_BUNDLE_OAUTH_MANAGER_STATE, managerState.serializeToJson()) + } else if (manager is PARCodeManager) { + val managerState = manager.toState() + bundle.putString(KEY_BUNDLE_PAR_MANAGER_STATE, managerState.serializeToJson()) } } - internal fun onRestoreInstanceState(bundle: Bundle) { + internal fun onRestoreInstanceState(bundle: Bundle, context: Context) { if (managerInstance == null) { - val stateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() - if (stateJson.isNotBlank()) { - val state = OAuthManagerState.deserializeState(stateJson) + val oauthStateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() + val parStateJson = bundle.getString(KEY_BUNDLE_PAR_MANAGER_STATE).orEmpty() + if (oauthStateJson.isNotBlank()) { + val state = OAuthManagerState.deserializeState(oauthStateJson) managerInstance = OAuthManager.fromState( state, object : Callback { override fun onSuccess(result: Credentials) { - for (callback in callbacks) { + if (callbacks.isNotEmpty()) { + for (callback in callbacks) { + callback.onSuccess(result) + } + } else { + pendingLoginResult.set(PendingResult.Success(result)) + } + } + + override fun onFailure(error: AuthenticationException) { + if (callbacks.isNotEmpty()) { + for (callback in callbacks) { + callback.onFailure(error) + } + } else { + pendingLoginResult.set(PendingResult.Failure(error)) + } + } + }, + context + ) + } else if (parStateJson.isNotBlank()) { + val state = PARCodeManagerState.deserializeState(parStateJson) + managerInstance = PARCodeManager.fromState( + state, + object : Callback { + override fun onSuccess(result: AuthorizationCode) { + for (callback in parCallbacks) { callback.onSuccess(result) } } override fun onFailure(error: AuthenticationException) { - for (callback in callbacks) { + for (callback in parCallbacks) { callback.onFailure(error) } } @@ -150,12 +281,41 @@ public object WebAuthProvider : SenderConstraining { managerInstance = null } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun resetState() { + managerInstance = null + callbacks.clear() + parCallbacks.clear() + pendingLoginResult.set(null) + pendingLogoutResult.set(null) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun setPendingLoginResult(credentials: Credentials) { + pendingLoginResult.set(PendingResult.Success(credentials)) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun setPendingLogoutResult() { + pendingLogoutResult.set(PendingResult.Success(null)) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun hasPendingLoginResult(): Boolean = pendingLoginResult.get() != null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun hasPendingLogoutResult(): Boolean = pendingLogoutResult.get() != null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun callbacksCount(): Int = callbacks.size + public class LogoutBuilder internal constructor(private val account: Auth0) { private var scheme = "https" private var returnToUrl: String? = null private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() private var federated: Boolean = false private var launchAsTwa: Boolean = false + private var authTab: Boolean = false private var customLogoutUrl: String? = null /** @@ -218,12 +378,35 @@ public object WebAuthProvider : SenderConstraining { * Launches the Logout experience with a native feel (without address bar). For this to work, * you have to setup the app as trusted following the steps mentioned [here](https://github.com/auth0/Auth0.Android/blob/main/EXAMPLES.md#trusted-web-activity-experimental). * + * Note: [withAuthTab] and [withTrustedWebActivity] are mutually exclusive. If both are set, + * TWA takes precedence and Auth Tab will not be used. They rely on different underlying + * launch mechanisms and cannot be combined. */ public fun withTrustedWebActivity(): LogoutBuilder { launchAsTwa = true return this } + /** + * Opts into using Auth Tab for the logout flow when the browser supports it. + * Auth Tab provides a dedicated, security-focused UI for OAuth flows with no address bar + * or share button. Falls back to a regular Custom Tab on browsers that do not support it. + * + * **Warning:** Auth Tab support in Auth0.Android is still experimental and can change in + * the future. + * + * Note: [withAuthTab] and [withTrustedWebActivity] are mutually exclusive. If both are set, + * TWA takes precedence and Auth Tab will not be used. They rely on different underlying + * launch mechanisms and cannot be combined. + * + * @return the current builder instance + */ + @ExperimentalAuth0Api + public fun withAuthTab(): LogoutBuilder { + authTab = true + return this + } + /** * Specifies a custom Logout URL to use for this logout request, overriding the default * generated from the Auth0 domain (account.logoutUrl). @@ -247,8 +430,31 @@ public object WebAuthProvider : SenderConstraining { * @see AuthenticationException.isAuthenticationCanceled */ public fun start(context: Context, callback: Callback) { + pendingLogoutResult.set(null) + pendingLoginResult.set(null) + + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { _: Void?, error: AuthenticationException? -> + if (error != null) { + pendingLogoutResult.set(PendingResult.Failure(error)) + } else { + pendingLogoutResult.set(PendingResult.Success(null)) + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + private fun startInternal(context: Context, callback: Callback) { resetManagerInstance() - if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { + val effectiveCtOptions = if (authTab) ctOptions.copyWithAuthTab() else ctOptions + if (!effectiveCtOptions.hasCompatibleBrowser(context.packageManager)) { val ex = AuthenticationException( "a0.browser_not_available", "No compatible Browser application is installed." @@ -267,7 +473,7 @@ public object WebAuthProvider : SenderConstraining { account, callback, returnToUrl!!, - ctOptions, + effectiveCtOptions, federated, launchAsTwa, customLogoutUrl @@ -291,7 +497,7 @@ public object WebAuthProvider : SenderConstraining { ) { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + startInternal(context, object : Callback { override fun onSuccess(result: Void?) { continuation.resume(Unit) } @@ -305,7 +511,8 @@ public object WebAuthProvider : SenderConstraining { } } - public class Builder internal constructor(private val account: Auth0) { + public class Builder internal constructor(private val account: Auth0) : + SenderConstraining { private val values: MutableMap = mutableMapOf() private val headers: MutableMap = mutableMapOf() private var pkce: PKCE? = null @@ -313,9 +520,12 @@ public object WebAuthProvider : SenderConstraining { private var scheme: String = "https" private var redirectUri: String? = null private var invitationUrl: String? = null + private var dPoP: DPoP? = null private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() private var leeway: Int? = null private var launchAsTwa: Boolean = false + private var ephemeralBrowsing: Boolean = false + private var authTab: Boolean = false private var customAuthorizeUrl: String? = null /** @@ -524,12 +734,54 @@ public object WebAuthProvider : SenderConstraining { * Launches the Login experience with a native feel (without address bar). For this to work, * you have to setup the app as trusted following the steps mentioned [here](https://github.com/auth0/Auth0.Android/blob/main/EXAMPLES.md#trusted-web-activity-experimental). * + * Note: [withAuthTab] and [withTrustedWebActivity] are mutually exclusive. If both are set, + * TWA takes precedence and Auth Tab will not be used. They rely on different underlying + * launch mechanisms and cannot be combined. */ public fun withTrustedWebActivity(): Builder { launchAsTwa = true return this } + /** + * Enable ephemeral browsing for the Custom Tab used in the login flow. + * When enabled, the Custom Tab runs in an isolated session — cookies, cache, + * history, and credentials are deleted when the tab closes. + * Requires Chrome 136+ or a compatible browser. On unsupported browsers, + * a warning is logged and a regular Custom Tab is used instead. + * + * **Warning:** Ephemeral browsing support in Auth0.Android is still experimental + * and can change in the future. Please test it thoroughly in all the targeted browsers + * and OS variants and let us know your feedback. + * + * @return the current builder instance + */ + @ExperimentalAuth0Api + public fun withEphemeralBrowsing(): Builder { + ephemeralBrowsing = true + return this + } + + /** + * Opts into using Auth Tab for the authentication flow when the browser supports it. + * Auth Tab provides a dedicated, security-focused UI for OAuth flows with no address bar + * or share button. Falls back to a regular Custom Tab on browsers that do not support it. + * + * **Warning:** Auth Tab support in Auth0.Android is still experimental and can change in + * the future. + * + * Note: [withAuthTab] and [withTrustedWebActivity] are mutually exclusive. If both are set, + * TWA takes precedence and Auth Tab will not be used. They rely on different underlying + * launch mechanisms and cannot be combined. + * + * @return the current builder instance + */ + @ExperimentalAuth0Api + public fun withAuthTab(): Builder { + authTab = true + return this + } + /** * Specifies a custom Authorize URL to use for this login request, overriding the default * generated from the Auth0 domain (account.authorizeUrl). @@ -548,6 +800,18 @@ public object WebAuthProvider : SenderConstraining { return this } + /** + * Enable DPoP (Demonstrating Proof-of-Possession) for this authentication request. + * DPoP binds access tokens to the client's cryptographic key, providing enhanced security. + * + * @param context the Android context used to access the keystore for DPoP key management + * @return the current builder instance + */ + public override fun useDPoP(context: Context): Builder { + dPoP = DPoP(context) + return this + } + /** * Request user Authentication. The result will be received in the callback. * An error is raised if there are no browser applications installed in the device, or if @@ -563,6 +827,38 @@ public object WebAuthProvider : SenderConstraining { public fun start( context: Context, callback: Callback + ) { + pendingLoginResult.set(null) + pendingLogoutResult.set(null) + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { success: Credentials?, error: AuthenticationException? -> + if (callbacks.isNotEmpty()) { + if (success != null) { + for (cb in callbacks) { cb.onSuccess(success) } + } else if (error != null) { + for (cb in callbacks) { cb.onFailure(error) } + } + } else { + if (success != null) { + pendingLoginResult.set(PendingResult.Success(success)) + } else if (error != null) { + pendingLoginResult.set(PendingResult.Failure(error)) + } + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + private fun startInternal( + context: Context, + callback: Callback ) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { @@ -588,8 +884,13 @@ public object WebAuthProvider : SenderConstraining { values[OAuthManager.KEY_ORGANIZATION] = organizationId values[OAuthManager.KEY_INVITATION] = invitationId } + + var effectiveCtOptions = ctOptions + if (ephemeralBrowsing) effectiveCtOptions = effectiveCtOptions.copyWithEphemeralBrowsing() + if (authTab) effectiveCtOptions = effectiveCtOptions.copyWithAuthTab() + val manager = OAuthManager( - account, callback, values, ctOptions, launchAsTwa, + account, callback, values, effectiveCtOptions, launchAsTwa, customAuthorizeUrl, dPoP ) manager.setHeaders(headers) @@ -604,7 +905,7 @@ public object WebAuthProvider : SenderConstraining { account.getDomainUrl() ) } - manager.startAuthentication(context, redirectUri!!, 110) + manager.startAuthentication(context, redirectUri!!, AUTH_REQUEST_CODE) } /** @@ -629,7 +930,9 @@ public object WebAuthProvider : SenderConstraining { ): Credentials { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + // Use startInternal directly — the anonymous callback captures only the + // coroutine continuation, not an Activity, so lifecycle wrapping is not needed + startInternal(context, object : Callback { override fun onSuccess(result: Credentials) { continuation.resume(result) } @@ -647,4 +950,140 @@ public object WebAuthProvider : SenderConstraining { private const val KEY_CONNECTION_SCOPE = "connection_scope" } } + + /** + * Builder for PAR (Pushed Authorization Request) code-only authentication flows. + * + * Use this builder when your backend (BFF) has already called the PAR endpoint + * and you need to complete the authorization flow by opening the authorize URL + * with the request_uri. + * + * Example usage: + * ```kotlin + * val authCode = WebAuthProvider.authorizeWithRequestUri(account) + * .await(context, requestUri) + * // Send authCode.code to your BFF for token exchange + * // IMPORTANT: Validate authCode.state against the state your BFF + * // used in the PAR request to prevent CSRF attacks. + * ``` + */ + public class PARBuilder internal constructor(private val account: Auth0) { + private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() + private var sessionTransferToken: String? = null + + /** + * When using a Custom Tabs compatible Browser, apply these customization options. + * + * @param options the Custom Tabs customization options + * @return the current builder instance + */ + public fun withCustomTabsOptions(options: CustomTabsOptions): PARBuilder { + ctOptions = options + return this + } + + /** + * Provide a session transfer token to be passed as a query parameter to the /authorize endpoint. + * This enables web single sign-on by transferring an existing session to the browser. + * + * @param token the session transfer token obtained from [AuthenticationAPIClient.ssoExchange] + * @return the current builder instance + */ + public fun withSessionTransferToken(token: String): PARBuilder { + this.sessionTransferToken = token + return this + } + + /** + * Start the PAR authorization flow using a request_uri from a PAR response. + * Opens the browser with the authorize URL and returns the authorization code + * for the app to exchange via BFF. + * + * @param context An Activity context to run the authentication. + * @param requestUri The request_uri obtained from the PAR endpoint (must start with "urn:ietf:params:oauth:request_uri:") + * @param callback Callback with authorization code result + */ + public fun start( + context: Context, + requestUri: String, + callback: Callback + ) { + resetManagerInstance() + + if (!PARUtils.isValidRequestUri(requestUri)) { + val ex = AuthenticationException( + "a0.invalid_request_uri", + "The request_uri must start with \"${PARUtils.REQUEST_URI_PREFIX}\"." + ) + callback.onFailure(ex) + return + } + + if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { + val ex = AuthenticationException( + "a0.browser_not_available", + "No compatible Browser application is installed." + ) + callback.onFailure(ex) + return + } + + val manager = PARCodeManager( + account = account, + callback = callback, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptions + ) + + managerInstance = manager + manager.startAuthentication(context, AUTH_REQUEST_CODE) + } + + /** + * Start the PAR authorization flow using a request_uri from a PAR response. + * Opens the browser with the authorize URL and returns the authorization code + * for the app to exchange via BFF. + * + * @param context An Activity context to run the authentication. + * @param requestUri The request_uri obtained from the PAR endpoint (must start with "urn:ietf:params:oauth:request_uri:") + * @return AuthorizationCode containing the authorization code + * @throws AuthenticationException if authentication fails or the request_uri is invalid + */ + @JvmSynthetic + @Throws(AuthenticationException::class) + public suspend fun await( + context: Context, + requestUri: String + ): AuthorizationCode { + return await(context, requestUri, Dispatchers.Main.immediate) + } + + /** + * Used internally so that [CoroutineContext] can be injected for testing purpose + */ + internal suspend fun await( + context: Context, + requestUri: String, + coroutineContext: CoroutineContext + ): AuthorizationCode { + return withContext(coroutineContext) { + suspendCancellableCoroutine { continuation -> + start( + context, + requestUri, + object : Callback { + override fun onSuccess(result: AuthorizationCode) { + continuation.resume(result) + } + + override fun onFailure(error: AuthenticationException) { + continuation.resumeWithException(error) + } + } + ) + } + } + } + } } diff --git a/auth0/src/main/java/com/auth0/android/request/DefaultClient.kt b/auth0/src/main/java/com/auth0/android/request/DefaultClient.kt index 157f4f6b7..0ea2c46db 100644 --- a/auth0/src/main/java/com/auth0/android/request/DefaultClient.kt +++ b/auth0/src/main/java/com/auth0/android/request/DefaultClient.kt @@ -9,7 +9,6 @@ import okhttp3.Headers import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.Interceptor import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -24,25 +23,164 @@ import javax.net.ssl.X509TrustManager /** * Default implementation of a Networking Client. + * + * Use [DefaultClient.Builder] to create a new instance with custom configuration: + * + * ```kotlin + * val client = DefaultClient.Builder() + * .connectTimeout(30) + * .readTimeout(30) + * .writeTimeout(30) + * .enableLogging(true) + * .build() + * ``` + * + * The legacy constructor-based API is still supported for backward compatibility. */ -public class DefaultClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( - connectTimeout: Int, - readTimeout: Int, +public class DefaultClient private constructor( private val defaultHeaders: Map, - enableLogging: Boolean, private val gson: Gson, - sslSocketFactory: SSLSocketFactory?, - trustManager: X509TrustManager? + okHttpClientBuilder: OkHttpClient.Builder ) : NetworkingClient { /** - * Create a new DefaultClient. + * Builder for creating a [DefaultClient] instance with custom configuration. * - * @param connectTimeout the connection timeout, in seconds, to use when executing requests. Default is ten seconds. - * @param readTimeout the read timeout, in seconds, to use when executing requests. Default is ten seconds. - * @param defaultHeaders any headers that should be sent on all requests. If a specific request specifies a header with the same key as any header in the default headers, the header specified on the request will take precedence. Default is an empty map. - * @param enableLogging whether HTTP request and response info should be logged. This should only be set to `true` for debugging purposes in non-production environments, as sensitive information is included in the logs. Defaults to `false`. + * Example usage: + * ```kotlin + * val client = DefaultClient.Builder() + * .connectTimeout(30) + * .readTimeout(30) + * .writeTimeout(30) + * .callTimeout(60) + * .defaultHeaders(mapOf("X-Custom" to "value")) + * .enableLogging(true) + * .logger(myCustomLogger) + * .build() + * ``` */ + public class Builder { + private var connectTimeout: Int = DEFAULT_TIMEOUT_SECONDS + private var readTimeout: Int = DEFAULT_TIMEOUT_SECONDS + private var writeTimeout: Int = DEFAULT_TIMEOUT_SECONDS + private var callTimeout: Int = 0 + private var defaultHeaders: Map = mapOf() + private var enableLogging: Boolean = false + private var logger: HttpLoggingInterceptor.Logger? = null + private var gson: Gson = GsonProvider.gson + private var sslSocketFactory: SSLSocketFactory? = null + private var trustManager: X509TrustManager? = null + + /** + * Sets the connection timeout, in seconds. Default is 10 seconds. + */ + public fun connectTimeout(timeout: Int): Builder = apply { this.connectTimeout = timeout } + + /** + * Sets the read timeout, in seconds. Default is 10 seconds. + */ + public fun readTimeout(timeout: Int): Builder = apply { this.readTimeout = timeout } + + /** + * Sets the write timeout, in seconds. Default is 10 seconds. + */ + public fun writeTimeout(timeout: Int): Builder = apply { this.writeTimeout = timeout } + + /** + * Sets the call timeout, in seconds. Default is 0 (no timeout). + * This is an overall timeout that spans the entire call: resolving DNS, connecting, + * writing the request body, server processing, and reading the response body. + */ + public fun callTimeout(timeout: Int): Builder = apply { this.callTimeout = timeout } + + /** + * Sets default headers to include on all requests. If a specific request specifies + * a header with the same key, the request-level header takes precedence. + */ + public fun defaultHeaders(headers: Map): Builder = + apply { this.defaultHeaders = headers } + + /** + * Enables or disables HTTP logging. Should only be set to `true` for debugging + * in non-production environments, as sensitive information may be logged. + * Defaults to `false`. + */ + public fun enableLogging(enable: Boolean): Builder = apply { this.enableLogging = enable } + + /** + * Sets a custom logger for the HTTP logging interceptor. + * Only takes effect if [enableLogging] is set to `true`. + * If not set, the default [HttpLoggingInterceptor.Logger] (which logs to logcat) is used. + */ + public fun logger(logger: HttpLoggingInterceptor.Logger): Builder = + apply { this.logger = logger } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun gson(gson: Gson): Builder = apply { this.gson = gson } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun sslSocketFactory( + sslSocketFactory: SSLSocketFactory, + trustManager: X509TrustManager + ): Builder = apply { + this.sslSocketFactory = sslSocketFactory + this.trustManager = trustManager + } + + /** + * Builds a new [DefaultClient] instance with the configured options. + */ + public fun build(): DefaultClient { + val okBuilder = OkHttpClient.Builder() + + okBuilder.addInterceptor(RetryInterceptor()) + + if (enableLogging) { + val loggingInterceptor = if (logger != null) { + HttpLoggingInterceptor(logger!!) + } else { + HttpLoggingInterceptor() + } + loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY) + okBuilder.addInterceptor(loggingInterceptor) + } + + okBuilder.connectTimeout(connectTimeout.toLong(), TimeUnit.SECONDS) + okBuilder.readTimeout(readTimeout.toLong(), TimeUnit.SECONDS) + okBuilder.writeTimeout(writeTimeout.toLong(), TimeUnit.SECONDS) + okBuilder.callTimeout(callTimeout.toLong(), TimeUnit.SECONDS) + + val ssl = sslSocketFactory + val tm = trustManager + if (ssl != null && tm != null) { + okBuilder.sslSocketFactory(ssl, tm) + } + + return DefaultClient(defaultHeaders, gson, okBuilder) + } + } + + /** + * Create a new DefaultClient with default configuration. + * + * For more configuration options, use [DefaultClient.Builder]. + * + * @param connectTimeout the connection timeout, in seconds. Default is 10 seconds. + * @param readTimeout the read timeout, in seconds. Default is 10 seconds. + * @param defaultHeaders headers to include on all requests. Default is an empty map. + * @param enableLogging whether to log HTTP request/response info. Defaults to `false`. + */ + @Deprecated( + message = "Use DefaultClient.Builder() for more configuration options.", + replaceWith = ReplaceWith( + "DefaultClient.Builder()" + + ".connectTimeout(connectTimeout)" + + ".readTimeout(readTimeout)" + + ".defaultHeaders(defaultHeaders)" + + ".enableLogging(enableLogging)" + + ".build()" + ) + ) @JvmOverloads public constructor( connectTimeout: Int = DEFAULT_TIMEOUT_SECONDS, @@ -50,13 +188,16 @@ public class DefaultClient @VisibleForTesting(otherwise = VisibleForTesting.PRIV defaultHeaders: Map = mapOf(), enableLogging: Boolean = false, ) : this( - connectTimeout, - readTimeout, - defaultHeaders, - enableLogging, - GsonProvider.gson, - null, - null + defaultHeaders = defaultHeaders, + gson = GsonProvider.gson, + okHttpClientBuilder = OkHttpClient.Builder().apply { + addInterceptor(RetryInterceptor()) + if (enableLogging) { + addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + connectTimeout(connectTimeout.toLong(), TimeUnit.SECONDS) + readTimeout(readTimeout.toLong(), TimeUnit.SECONDS) + } ) @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @@ -125,35 +266,14 @@ public class DefaultClient @VisibleForTesting(otherwise = VisibleForTesting.PRIV } init { - val builder = OkHttpClient.Builder() - // Add retry interceptor - builder.addInterceptor(RetryInterceptor()) - - // logging - if (enableLogging) { - val logger: Interceptor = HttpLoggingInterceptor() - .setLevel(HttpLoggingInterceptor.Level.BODY) - builder.addInterceptor(logger) - } - - // timeouts - builder.connectTimeout(connectTimeout.toLong(), TimeUnit.SECONDS) - builder.readTimeout(readTimeout.toLong(), TimeUnit.SECONDS) + okHttpClient = okHttpClientBuilder.build() - // testing with ssl hook (internal constructor params visibility only) - if (sslSocketFactory != null && trustManager != null) { - builder.sslSocketFactory(sslSocketFactory, trustManager) - } - - okHttpClient = builder.build() - - // Non-retryable client for DPoP requests + // Non-retryable client for DPoP requests — inherits all configuration nonRetryableOkHttpClient = okHttpClient.newBuilder() .retryOnConnectionFailure(false) .build() } - internal companion object { const val DEFAULT_TIMEOUT_SECONDS: Int = 10 val APPLICATION_JSON_UTF8: MediaType = diff --git a/auth0/src/main/java/com/auth0/android/request/UserData.kt b/auth0/src/main/java/com/auth0/android/request/UserData.kt index 693b64368..e22850bb8 100644 --- a/auth0/src/main/java/com/auth0/android/request/UserData.kt +++ b/auth0/src/main/java/com/auth0/android/request/UserData.kt @@ -8,10 +8,22 @@ import com.google.gson.annotations.SerializedName * @param phoneNumber the phone number of the user. phone number can be optional, required, or forbidden depending on the attribute configuration for the database * @param userName the username of the user. username can be optional, required, or forbidden depending on the attribute configuration for the database * @param name optional display name + * @param givenName the first name of the user + * @param familyName the last name of the user + * @param nickName the preferred nickname of the user + * @param picture URL pointing to the user's profile picture + * @param userMetadata additional user metadata as key-value pairs */ public data class UserData( @field:SerializedName("email") val email: String? = null, @field:SerializedName("phone_number") val phoneNumber: String? = null, @field:SerializedName("username") val userName: String? = null, @field:SerializedName("name") val name: String? = null, -) \ No newline at end of file + @field:SerializedName("given_name") val givenName: String? = null, + @field:SerializedName("family_name") val familyName: String? = null, + @field:SerializedName("nickname") val nickName: String? = null, + @field:SerializedName("picture") val picture: String? = null, + @field:SerializedName("user_metadata") val userMetadata: Map? = null, +) { + internal fun toUserProfile(): UserData = copy(userMetadata = null) +} diff --git a/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt b/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt index 9157f5bac..a927a89dd 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt @@ -2,6 +2,7 @@ package com.auth0.android.request.internal import androidx.annotation.VisibleForTesting import com.auth0.android.result.Credentials +import com.auth0.android.result.SSOCredentials import com.auth0.android.result.UserProfile import com.google.gson.Gson import com.google.gson.GsonBuilder @@ -25,6 +26,7 @@ internal object GsonProvider { .registerTypeAdapterFactory(JsonRequiredTypeAdapterFactory()) .registerTypeAdapter(UserProfile::class.java, UserProfileDeserializer()) .registerTypeAdapter(Credentials::class.java, CredentialsDeserializer()) + .registerTypeAdapter(SSOCredentials::class.java, SSOCredentialsDeserializer()) .registerTypeAdapter(jwksType, JwksDeserializer()) .setDateFormat(DATE_FORMAT) .create() diff --git a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt index 09558f650..98eb39fa9 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt @@ -30,6 +30,14 @@ internal class Jwt(rawToken: String) { val authenticationTime: Date? val audience: List + /** + * The IPSIE `session_expiry` claim: an absolute session-expiry ceiling in **Unix seconds** + * asserted by the upstream identity provider. Null when the connection does not emit the claim + * or the value is not a plausible Unix-seconds timestamp (see [MAX_PLAUSIBLE_SESSION_EXPIRY]), + * both of which MUST be treated as "no ceiling". + */ + val sessionExpiry: Long? + init { parts = splitToken(rawToken) val jsonHeader = decodeBase64(parts[0]) @@ -53,6 +61,12 @@ internal class Jwt(rawToken: String) { authorizedParty = decodedPayload["azp"] as String? authenticationTime = (decodedPayload["auth_time"] as? Double)?.let { Date(it.toLong() * 1000) } + // `session_expiry` is customer-authored and expected in Unix *seconds*. A value mistakenly + // emitted in milliseconds would parse as a timestamp ~50,000 years out and silently disable + // the ceiling (fail-open), so reject implausibly large values and treat them as "no ceiling". + // `as? Number` (not `as? Double`) so a JSON value deserialized as a Long is not dropped. + sessionExpiry = (decodedPayload["session_expiry"] as? Number)?.toLong() + ?.takeIf { it < MAX_PLAUSIBLE_SESSION_EXPIRY } audience = when (val aud = decodedPayload["aud"]) { is String -> listOf(aud) is List<*> -> aud as List @@ -61,6 +75,13 @@ internal class Jwt(rawToken: String) { } companion object { + /** + * Upper bound (exclusive) for a plausible `session_expiry` in Unix seconds: 10,000,000,000 + * (year ~2286). A value at or above this is almost certainly milliseconds and is treated as + * "no ceiling" rather than a date tens of thousands of years out. + */ + private const val MAX_PLAUSIBLE_SESSION_EXPIRY = 10_000_000_000L + fun splitToken(token: String): Array { var parts = token.split(".").toTypedArray() if (parts.size == 2 && token.endsWith(".")) { diff --git a/auth0/src/main/java/com/auth0/android/request/internal/SSOCredentialsDeserializer.kt b/auth0/src/main/java/com/auth0/android/request/internal/SSOCredentialsDeserializer.kt new file mode 100644 index 000000000..bcc5f657d --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/internal/SSOCredentialsDeserializer.kt @@ -0,0 +1,70 @@ +package com.auth0.android.request.internal + +import androidx.annotation.VisibleForTesting +import com.auth0.android.result.SSOCredentials +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type +import java.util.Date + +internal open class SSOCredentialsDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): SSOCredentials { + if (!json.isJsonObject || json.isJsonNull || json.asJsonObject.entrySet().isEmpty()) { + throw JsonParseException("sso credentials json is not a valid json object") + } + val jsonObject = json.asJsonObject + val sessionTransferToken = + context.deserialize(jsonObject.remove("access_token"), String::class.java) + val idToken = + context.deserialize(jsonObject.remove("id_token"), String::class.java) + val issuedTokenType = + context.deserialize(jsonObject.remove("issued_token_type"), String::class.java) + val tokenType = + context.deserialize(jsonObject.remove("token_type"), String::class.java) + val expiresIn = + context.deserialize(jsonObject.remove("expires_in"), Long::class.java) + val refreshToken = + context.deserialize(jsonObject.remove("refresh_token"), String::class.java) + + var expiresInDate: Date? + if (expiresIn != null) { + expiresInDate = Date(currentTimeInMillis + expiresIn * 1000) + } else { + throw JsonParseException("Missing the required property expires_in") + } + + return createSSOCredentials( + sessionTransferToken, + idToken, + issuedTokenType, + tokenType, + expiresInDate!!, + refreshToken + ) + } + + @get:VisibleForTesting + open val currentTimeInMillis: Long + get() = System.currentTimeMillis() + + @VisibleForTesting + open fun createSSOCredentials( + sessionTransferToken: String, + idToken: String, + issuedTokenType: String, + tokenType: String, + expiresAt: Date, + refreshToken: String? + ): SSOCredentials { + return SSOCredentials( + sessionTransferToken, idToken, issuedTokenType, tokenType, expiresAt, refreshToken + ) + } +} diff --git a/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java b/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java index b40fbffd1..57259286f 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java +++ b/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java @@ -1,5 +1,8 @@ package com.auth0.android.request.internal; +import android.util.Log; + +import com.auth0.android.result.ActorClaim; import com.auth0.android.result.UserIdentity; import com.auth0.android.result.UserProfile; import com.google.gson.Gson; @@ -11,12 +14,15 @@ import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; class UserProfileDeserializer implements JsonDeserializer { + private static final String TAG = UserProfileDeserializer.class.getSimpleName(); + private final Gson iso8601DateGson; public UserProfileDeserializer() { @@ -44,10 +50,35 @@ public UserProfile deserialize(JsonElement json, Type typeOfT, JsonDeserializati final Type identitiesType = new TypeToken>() {}.getType(); final List identities = context.deserialize(object.remove("identities"), identitiesType); + final ActorClaim actor = deserializeActorClaim(object.remove("act"), context); + final Type metadataType = new TypeToken>() {}.getType(); Map userMetadata = context.deserialize(object.remove("user_metadata"), metadataType); Map appMetadata = context.deserialize(object.remove("app_metadata"), metadataType); Map extraInfo = context.deserialize(object, metadataType); - return new UserProfile(id, name, nickname, picture, email, emailVerified, familyName, createdAt, identities, extraInfo, userMetadata, appMetadata, givenName); + return new UserProfile(id, name, nickname, picture, email, emailVerified, familyName, createdAt, identities, extraInfo, userMetadata, appMetadata, givenName, actor); + } + + private ActorClaim deserializeActorClaim(JsonElement actElement, JsonDeserializationContext context) { + if (actElement == null || actElement.isJsonNull() || !actElement.isJsonObject()) { + return null; + } + + JsonObject actObject = actElement.getAsJsonObject(); + String sub = context.deserialize(actObject.remove("sub"), String.class); + if (sub == null) { + Log.w(TAG, "act claim present but missing required 'sub' field, ignoring actor"); + return null; + } + + ActorClaim nestedActor = deserializeActorClaim(actObject.remove("act"), context); + + final Type mapType = new TypeToken>() {}.getType(); + Map extraProperties = context.deserialize(actObject, mapType); + if (extraProperties == null) { + extraProperties = Collections.emptyMap(); + } + + return new ActorClaim(sub, nestedActor, extraProperties); } } diff --git a/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt b/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt new file mode 100644 index 000000000..4f6998890 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt @@ -0,0 +1,17 @@ +package com.auth0.android.result + +import java.io.Serializable + +/** + * Represents the `act` (actor) claim in an ID token, used in delegation and impersonation scenarios. + * See RFC 8693 Section 4.4 for the specification of the `act` claim. + * + * @param sub The unique identifier of the actor (required). + * @param actor A nested actor claim representing a delegation chain. + * @param extraProperties Additional custom properties set via the `setActor` Action command. + */ +public data class ActorClaim( + val sub: String, + val actor: ActorClaim? = null, + val extraProperties: Map = emptyMap() +) : Serializable diff --git a/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt new file mode 100644 index 000000000..071d8420f --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt @@ -0,0 +1,27 @@ +package com.auth0.android.result + +/** + * Result returned when the SDK completes a PAR (Pushed Authorization Request) flow. + * Contains the authorization code that should be sent to your backend (BFF) for token exchange. + * + * **Important:** The SDK does not validate the [state] parameter. Your app or BFF must validate + * that the returned [state] matches the value originally used in the PAR request to prevent + * CSRF attacks. + * + * @property code The authorization code received from Auth0. + * @property state The optional state parameter received from Auth0, if present. + */ +public data class AuthorizationCode( + /** + * The authorization code received from Auth0. + * This code should be sent to your BFF for token exchange. + */ + public val code: String, + + /** + * The optional state parameter received from Auth0. + * Your app or BFF must validate this against the original state used in the + * PAR request to prevent CSRF attacks. + */ + public val state: String? = null +) diff --git a/auth0/src/main/java/com/auth0/android/result/Challenge.kt b/auth0/src/main/java/com/auth0/android/result/Challenge.kt index b34231a61..7793c56c3 100755 --- a/auth0/src/main/java/com/auth0/android/result/Challenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/Challenge.kt @@ -6,7 +6,7 @@ import com.google.gson.annotations.SerializedName /** * Multi-factor authentication (MFA) challenge * - * @see [com.auth0.android.authentication.AuthenticationAPIClient.multifactorChallenge] + * @see [com.auth0.android.authentication.mfa.MfaApiClient] */ public class Challenge( @field:JsonRequired @field:SerializedName("challenge_type") diff --git a/auth0/src/main/java/com/auth0/android/result/Credentials.kt b/auth0/src/main/java/com/auth0/android/result/Credentials.kt index ec32ed1f3..9565b66cd 100755 --- a/auth0/src/main/java/com/auth0/android/result/Credentials.kt +++ b/auth0/src/main/java/com/auth0/android/result/Credentials.kt @@ -79,4 +79,27 @@ public data class Credentials( return gson.fromJson(Jwt.decodeBase64(payload), UserProfile::class.java) } + /** + * The session-expiry ceiling pinned at the initial login, in Unix seconds, stamped by the + * credentials manager when it serves these credentials. When set, it is the value actually + * enforced by the SDK and takes precedence over the live [idToken] claim — so [sessionExpiresAt] + * does not diverge from enforcement after a refresh whose token omits or re-emits the claim. + */ + @Transient + internal var pinnedSessionExpiresAt: Long? = null + + /** + * The absolute session-expiry ceiling, in **Unix seconds**, asserted by the upstream identity + * provider via the IPSIE `session_expiry` claim, or `null` when the connection does not emit it. + * + * This is a session-level ceiling that is independent of [expiresAt] (the access-token expiry): + * it usually sits much further out and caps how long the local session may live, regardless of + * access-token renewals. A `null` value means there is no such ceiling. + * + * When these credentials were served by a credentials manager, this reflects the value pinned at + * the initial login (the one the SDK enforces). Otherwise it is decoded on demand from [idToken]. + */ + public val sessionExpiresAt: Long? + get() = pinnedSessionExpiresAt ?: runCatching { Jwt(idToken).sessionExpiry }.getOrNull() + } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt index 5357a3fc0..1ce73d986 100644 --- a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt @@ -24,7 +24,8 @@ public sealed class EnrollmentChallenge { jsonObject.has("barcode_uri") -> TotpEnrollmentChallenge::class.java jsonObject.has("recovery_code") -> RecoveryCodeEnrollmentChallenge::class.java jsonObject.has("authn_params_public_key") -> PasskeyEnrollmentChallenge::class.java - jsonObject.has("oob_code") -> OobEnrollmentChallenge::class.java + jsonObject.has("oob_code") -> OobEnrollmentChallenge::class.java + jsonObject.has("policy") -> PasswordEnrollmentChallenge::class.java else -> MfaEnrollmentChallenge::class.java } return context.deserialize(jsonObject, targetClass) @@ -54,15 +55,53 @@ public data class OobEnrollmentChallenge( public val bindingMethod: String? = null ) : EnrollmentChallenge() +/** + * Enrollment challenge for TOTP (authenticator app) and Push factors, returned by both + * the MFA `/mfa/associate` endpoint and the My Account `/authentication-methods` endpoint. + * + * The two endpoints return different field sets, so every field except [barcodeUri] is + * optional: + * - `/mfa/associate` (ROPG MFA flow) returns [authenticatorType], [secret], [barcodeUri] + * and [recoveryCodes]. It does NOT return `id`, `auth_session` or `manual_input_code`. + * - `/authentication-methods` (My Account) returns [id], [authSession], [barcodeUri] and + * [manualInputCode]. + * + * [secret] and [manualInputCode] both carry the human-readable key for manual entry into an + * authenticator app; only one is populated depending on the endpoint. The [barcodeUri] + * (`otpauth://` URI) is always present and embeds the same secret. + * + * @property id Identifier of the created authentication method. `null` on the `/mfa/associate` + * response (which does not return it); populated by the My Account `/authentication-methods` + * endpoint. + * @property authSession Authentication session for the enrollment. `null` on the + * `/mfa/associate` response; populated by the My Account `/authentication-methods` endpoint. + * @property barcodeUri The `otpauth://` URI to render as a QR code. Always present on both + * endpoints. + * @property manualInputCode Human-readable key for manual entry, returned only by the My Account + * `/authentication-methods` endpoint; `null` on the `/mfa/associate` response (which returns the + * key as [secret] instead). + * @property authenticatorType Low-level authenticator type (e.g. `otp`), returned only by the + * `/mfa/associate` endpoint; `null` on the My Account response. + * @property secret Human-readable key for manual entry, returned only by the `/mfa/associate` + * endpoint; `null` on the My Account response (which returns the key as [manualInputCode]). + * @property recoveryCodes Recovery codes generated during enrollment, returned only by the + * `/mfa/associate` endpoint; `null` on the My Account response. + */ public data class TotpEnrollmentChallenge( @SerializedName("id") - override val id: String, + override val id: String? = null, @SerializedName("auth_session") - override val authSession: String, + override val authSession: String? = null, @SerializedName("barcode_uri") public val barcodeUri: String, @SerializedName("manual_input_code") - public val manualInputCode: String? + public val manualInputCode: String? = null, + @SerializedName("authenticator_type") + public val authenticatorType: String? = null, + @SerializedName("secret") + public val secret: String? = null, + @SerializedName("recovery_codes") + public val recoveryCodes: List? = null ) : EnrollmentChallenge() public data class RecoveryCodeEnrollmentChallenge( @@ -72,4 +111,17 @@ public data class RecoveryCodeEnrollmentChallenge( override val authSession: String, @SerializedName("recovery_code") public val recoveryCode: String +) : EnrollmentChallenge() + +/** + * Enrollment challenge for a password authentication method. Includes the [policy] the new password + * must satisfy, so the app can guide the user before confirming the enrollment. + */ +public data class PasswordEnrollmentChallenge( + @SerializedName("id") + override val id: String, + @SerializedName("auth_session") + override val authSession: String, + @SerializedName("policy") + public val policy: PasswordPolicy ) : EnrollmentChallenge() \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt b/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt new file mode 100644 index 000000000..e1bb22ae0 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt @@ -0,0 +1,122 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Describes the password policy that a new password must satisfy, as returned by the My Account API + * when starting a password enrollment. Use it to build a UI that guides the user toward a compliant + * password. + */ +public data class PasswordPolicy( + /** + * Rules governing the structural complexity of the password (length, character types, etc.). + */ + @SerializedName("complexity") + public val complexity: PasswordComplexity, + /** + * Rules that prevent the password from containing personal information taken from the user profile. + */ + @SerializedName("profile_data") + public val profileData: PasswordProfileData, + /** + * Rules that prevent reuse of previously used passwords. + */ + @SerializedName("history") + public val history: PasswordHistory, + /** + * Rules that prevent the use of common dictionary words as passwords. + */ + @SerializedName("dictionary") + public val dictionary: PasswordDictionary +) + +/** + * Structural complexity requirements for a password. + */ +public data class PasswordComplexity( + /** + * The minimum number of characters the password must contain. + */ + @SerializedName("min_length") + public val minLength: Int?, + /** + * The character classes the password may be required to include. + * Possible values: `uppercase`, `lowercase`, `number`, `special`. + */ + @SerializedName("character_types") + public val characterTypes: List?, + /** + * How the [characterTypes] requirement is enforced. + * Possible values: `all` (every listed type is required), `three_of_four`. + */ + @SerializedName("character_type_rule") + public val characterTypeRule: String?, + /** + * Whether identical consecutive characters are permitted. + * Possible values: `allow`, `block`. + */ + @SerializedName("identical_characters") + public val identicalCharacters: String?, + /** + * Whether sequential characters (e.g. `abc`, `123`) are permitted. + * Possible values: `allow`, `block`. + */ + @SerializedName("sequential_characters") + public val sequentialCharacters: String?, + /** + * How a password that exceeds the maximum allowed length is handled. + * Possible values: `truncate`, `error`. + */ + @SerializedName("max_length_exceeded") + public val maxLengthExceeded: String? +) + +/** + * Rules that block the use of personal information from the user profile within a password. + */ +public data class PasswordProfileData( + /** + * Whether blocking of personal information is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The user profile fields whose values must not appear in the password + * (e.g. `name`, `email`, `user_metadata.first`). + */ + @SerializedName("blocked_fields") + public val blockedFields: List? +) + +/** + * Rules that prevent reuse of previously used passwords. + */ +public data class PasswordHistory( + /** + * Whether password history enforcement is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The number of previous passwords that cannot be reused. + */ + @SerializedName("size") + public val size: Int? +) + +/** + * Rules that prevent the use of common dictionary words as passwords. + */ +public data class PasswordDictionary( + /** + * Whether dictionary checking is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The default dictionary used for the check. + * Possible values: `en_10k`, `en_100k`. + */ + @SerializedName("default") + public val default: String? +) diff --git a/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt b/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt new file mode 100644 index 000000000..36e4f8e55 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt @@ -0,0 +1,17 @@ +package com.auth0.android.result + +import com.auth0.android.request.internal.JsonRequired +import com.google.gson.annotations.SerializedName + +/** + * Result of a passwordless challenge. + * + * Holds the opaque `auth_session` token returned when a passwordless challenge is issued. + * + * @see [com.auth0.android.authentication.passwordless.PasswordlessClient.challengeWithEmail] + * @see [com.auth0.android.authentication.passwordless.PasswordlessClient.challengeWithPhoneNumber] + */ +public class PasswordlessChallenge( + @field:JsonRequired @field:SerializedName("auth_session") + public val authSession: String +) diff --git a/auth0/src/main/java/com/auth0/android/result/SSOCredentials.kt b/auth0/src/main/java/com/auth0/android/result/SSOCredentials.kt index f56812087..dec78883c 100644 --- a/auth0/src/main/java/com/auth0/android/result/SSOCredentials.kt +++ b/auth0/src/main/java/com/auth0/android/result/SSOCredentials.kt @@ -1,6 +1,7 @@ package com.auth0.android.result import com.google.gson.annotations.SerializedName +import java.util.Date /** * Holds the token credentials required for web SSO. @@ -46,12 +47,12 @@ public data class SSOCredentials( @field:SerializedName("token_type") public val tokenType: String, /** - * Expiration duration of the session transfer token in seconds. Session transfer tokens are short-lived and expire after a few minutes. + * Expiration date of the session transfer token. Session transfer tokens are short-lived and expire after a few minutes. * Once expired, the session transfer tokens can no longer be used for web SSO. * - * @return the expiration duration of this session transfer token + * @return the expiration Date of this session transfer token */ - @field:SerializedName("expires_in") public val expiresIn: Int, + @field:SerializedName("expires_in") public val expiresAt: Date, /** * Rotated refresh token. Only available when Refresh Token Rotation is enabled. @@ -67,6 +68,6 @@ public data class SSOCredentials( ) { override fun toString(): String { - return "SSOCredentials(sessionTransferToken = ****, idToken = ****,issuedTokenType = $issuedTokenType, tokenType = $tokenType, expiresIn = $expiresIn, refreshToken = ****)" + return "SSOCredentials(sessionTransferToken = ****, idToken = ****,issuedTokenType = $issuedTokenType, tokenType = $tokenType, expiresAt = $expiresAt, refreshToken = ****)" } } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/UserProfile.kt b/auth0/src/main/java/com/auth0/android/result/UserProfile.kt index 2f4903139..feabaa361 100755 --- a/auth0/src/main/java/com/auth0/android/result/UserProfile.kt +++ b/auth0/src/main/java/com/auth0/android/result/UserProfile.kt @@ -5,9 +5,9 @@ import java.util.* /** * Class that holds the information of a user's profile in Auth0. - * Used both in [com.auth0.android.management.UsersAPIClient] and [com.auth0.android.authentication.AuthenticationAPIClient]. + * Used in [com.auth0.android.authentication.AuthenticationAPIClient]. */ -public class UserProfile( +public class UserProfile @JvmOverloads constructor( private val id: String?, public val name: String?, public val nickname: String?, @@ -25,7 +25,13 @@ public class UserProfile( private val extraInfo: Map?, private val userMetadata: Map?, private val appMetadata: Map?, - public val givenName: String? + public val givenName: String?, + /** + * The actor claim from the ID token, representing the acting party in delegation + * or impersonation scenarios (e.g., an AI agent acting on behalf of a user). + * Only present when the token was issued via Custom Token Exchange with an actor. + */ + public val actor: ActorClaim? = null ) : Serializable { /** diff --git a/auth0/src/test/java/com/auth0/android/Auth0Test.java b/auth0/src/test/java/com/auth0/android/Auth0Test.java index f924a95bb..52eabfd1b 100755 --- a/auth0/src/test/java/com/auth0/android/Auth0Test.java +++ b/auth0/src/test/java/com/auth0/android/Auth0Test.java @@ -23,7 +23,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import java.lang.reflect.Method; diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index 1eb962e33..ba435a449 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import com.auth0.android.Auth0 import com.auth0.android.authentication.ParameterBuilder.Companion.newBuilder +import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.DPoPKeyStore import com.auth0.android.dpop.DPoPUtil @@ -13,12 +14,12 @@ import com.auth0.android.provider.JwtTestUtils import com.auth0.android.request.HttpMethod import com.auth0.android.request.NetworkingClient import com.auth0.android.request.PublicKeyCredentials +import com.auth0.android.request.UserData import com.auth0.android.request.RequestOptions import com.auth0.android.request.ServerResponse import com.auth0.android.request.internal.RequestFactory import com.auth0.android.request.internal.ThreadSwitcherShadow import com.auth0.android.result.Authentication -import com.auth0.android.result.Challenge import com.auth0.android.result.Credentials import com.auth0.android.result.DatabaseUser import com.auth0.android.result.SSOCredentials @@ -32,12 +33,12 @@ import com.auth0.android.util.SSLTestUtils.testClient import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -139,6 +140,12 @@ public class AuthenticationAPIClientTest { assertThat(client.baseURL.toHttpUrlOrNull()!!.encodedPath, Matchers.`is`("/")) } + @Test + public fun shouldCreatePasswordlessClient() { + val client = AuthenticationAPIClient(Auth0.getInstance(CLIENT_ID, DOMAIN)) + assertThat(client.passwordlessClient(), Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun shouldCreateClientWithContextInfo() { val context: Context = mock() @@ -167,41 +174,6 @@ public class AuthenticationAPIClientTest { assertThat(client.baseURL, Matchers.equalTo("https://" + DOMAIN + "/")) } - @Test - public fun shouldLoginWithMFAOTPCode() { - mockAPI.willReturnSuccessfulLogin() - val callback = MockAuthenticationCallback() - val auth0 = auth0 - val client = AuthenticationAPIClient(auth0) - client.loginWithOTP("ey30.the-mfa-token.value", "123456") - .start(callback) - ShadowLooper.idleMainLooper() - assertThat( - callback, AuthenticationCallbackMatcher.hasPayloadOfType( - Credentials::class.java - ) - ) - val request = mockAPI.takeRequest() - assertThat( - request.getHeader("Accept-Language"), Matchers.`is`( - defaultLocale - ) - ) - val body = bodyFromRequest(request) - assertThat(request.path, Matchers.equalTo("/oauth/token")) - assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) - assertThat( - body, - Matchers.hasEntry("grant_type", "http://auth0.com/oauth/grant-type/mfa-otp") - ) - assertThat(body, Matchers.hasEntry("mfa_token", "ey30.the-mfa-token.value")) - assertThat(body, Matchers.hasEntry("otp", "123456")) - assertThat(body, Matchers.not(Matchers.hasKey("scope"))) - assertThat(body, Matchers.not(Matchers.hasKey("username"))) - assertThat(body, Matchers.not(Matchers.hasKey("password"))) - assertThat(body, Matchers.not(Matchers.hasKey("connection"))) - } - @Test public fun shouldSigninWithPasskey() { mockAPI.willReturnSuccessfulLogin() @@ -225,7 +197,7 @@ public class AuthenticationAPIClientTest { defaultLocale ) ) - val body = bodyFromRequest(request) + val body = bodyFromRequest(request) assertThat(request.path, Matchers.equalTo("/oauth/token")) assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) assertThat( @@ -243,7 +215,7 @@ public class AuthenticationAPIClientTest { val auth0 = auth0 val client = AuthenticationAPIClient(auth0) val registrationResponse = client.signupWithPasskey( - mock(), + UserData(email = "test@example.com"), MY_CONNECTION, "testOrganization" ) @@ -254,7 +226,7 @@ public class AuthenticationAPIClientTest { defaultLocale ) ) - val body = bodyFromRequest(request) + val body = bodyFromRequest(request) assertThat(request.path, Matchers.equalTo("/passkey/register")) assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) assertThat(body, Matchers.hasEntry("realm", MY_CONNECTION)) @@ -264,6 +236,49 @@ public class AuthenticationAPIClientTest { assertThat(registrationResponse.authSession, Matchers.comparesEqualTo(SESSION_ID)) } + @Test + public fun shouldSignupWithPasskeyWithAllUserDataFields() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + val userData = UserData( + email = "test@example.com", + phoneNumber = "+1234567890", + userName = "testuser", + name = "Test User", + givenName = "Test", + familyName = "User", + nickName = "testy", + picture = "https://example.com/photo.png", + userMetadata = mapOf("key1" to "value1") + ) + val registrationResponse = client.signupWithPasskey( + userData, + MY_CONNECTION, + "testOrganization" + ).execute() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/passkey/register")) + assertThat(body, Matchers.hasKey("user_profile")) + @Suppress("UNCHECKED_CAST") + val userProfile = body["user_profile"] as Map + assertThat(userProfile, Matchers.hasEntry("email", "test@example.com")) + assertThat(userProfile, Matchers.hasEntry("phone_number", "+1234567890")) + assertThat(userProfile, Matchers.hasEntry("username", "testuser")) + assertThat(userProfile, Matchers.hasEntry("name", "Test User")) + assertThat(userProfile, Matchers.hasEntry("given_name", "Test")) + assertThat(userProfile, Matchers.hasEntry("family_name", "User")) + assertThat(userProfile, Matchers.hasEntry("nickname", "testy")) + assertThat(userProfile, Matchers.hasEntry("picture", "https://example.com/photo.png")) + assertThat(userProfile, Matchers.not(Matchers.hasKey("user_metadata"))) + assertThat(body, Matchers.hasKey("user_metadata")) + @Suppress("UNCHECKED_CAST") + val metadata = body["user_metadata"] as Map + assertThat(metadata, Matchers.hasEntry("key1", "value1")) + assertThat(registrationResponse, Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun shouldGetPasskeyChallenge() { mockAPI.willReturnSuccessfulPasskeyChallenge() @@ -287,159 +302,6 @@ public class AuthenticationAPIClientTest { } - @Test - public fun shouldLoginWithMFARecoveryCode() { - mockAPI.willReturnSuccessfulLoginWithRecoveryCode() - val callback = MockAuthenticationCallback() - val auth0 = auth0 - val client = AuthenticationAPIClient(auth0) - client.loginWithRecoveryCode("ey30.the-mfa-token.value", "123456") - .start(callback) - ShadowLooper.idleMainLooper() - assertThat( - callback, AuthenticationCallbackMatcher.hasPayloadOfType( - Credentials::class.java - ) - ) - assertThat(callback.payload.recoveryCode, Matchers.`is`("654321")) - val request = mockAPI.takeRequest() - assertThat( - request.getHeader("Accept-Language"), Matchers.`is`( - defaultLocale - ) - ) - val body = bodyFromRequest(request) - assertThat(request.path, Matchers.equalTo("/oauth/token")) - assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) - assertThat( - body, - Matchers.hasEntry("grant_type", "http://auth0.com/oauth/grant-type/mfa-recovery-code") - ) - assertThat(body, Matchers.hasEntry("mfa_token", "ey30.the-mfa-token.value")) - assertThat(body, Matchers.hasEntry("recovery_code", "123456")) - assertThat(body, Matchers.not(Matchers.hasKey("scope"))) - } - - @Test - public fun shouldLoginWithMFAOOBCode() { - mockAPI.willReturnSuccessfulLogin() - val callback = MockAuthenticationCallback() - val auth0 = auth0 - val client = AuthenticationAPIClient(auth0) - client.loginWithOOB("ey30.the-mfa-token.value", "123456", null) - .start(callback) - ShadowLooper.idleMainLooper() - assertThat( - callback, AuthenticationCallbackMatcher.hasPayloadOfType( - Credentials::class.java - ) - ) - val request = mockAPI.takeRequest() - assertThat( - request.getHeader("Accept-Language"), Matchers.`is`( - defaultLocale - ) - ) - val body = bodyFromRequest(request) - assertThat(request.path, Matchers.equalTo("/oauth/token")) - assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) - assertThat( - body, - Matchers.hasEntry("grant_type", "http://auth0.com/oauth/grant-type/mfa-oob") - ) - assertThat(body, Matchers.hasEntry("mfa_token", "ey30.the-mfa-token.value")) - assertThat(body, Matchers.hasEntry("oob_code", "123456")) - assertThat(body, Matchers.not(Matchers.hasKey("scope"))) - assertThat(body, Matchers.not(Matchers.hasKey("binding_code"))) - } - - @Test - public fun shouldLoginWithMFAOOBCodeAndBindingCode() { - mockAPI.willReturnSuccessfulLogin() - val callback = MockAuthenticationCallback() - val auth0 = auth0 - val client = AuthenticationAPIClient(auth0) - client.loginWithOOB("ey30.the-mfa-token.value", "123456", "abcdefg") - .start(callback) - ShadowLooper.idleMainLooper() - assertThat( - callback, AuthenticationCallbackMatcher.hasPayloadOfType( - Credentials::class.java - ) - ) - val request = mockAPI.takeRequest() - assertThat( - request.getHeader("Accept-Language"), Matchers.`is`( - defaultLocale - ) - ) - val body = bodyFromRequest(request) - assertThat(request.path, Matchers.equalTo("/oauth/token")) - assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) - assertThat( - body, - Matchers.hasEntry("grant_type", "http://auth0.com/oauth/grant-type/mfa-oob") - ) - assertThat(body, Matchers.hasEntry("mfa_token", "ey30.the-mfa-token.value")) - assertThat(body, Matchers.hasEntry("oob_code", "123456")) - assertThat(body, Matchers.hasEntry("binding_code", "abcdefg")) - assertThat(body, Matchers.not(Matchers.hasKey("scope"))) - } - - @Test - public fun shouldStartMFAChallenge() { - mockAPI.willReturnSuccessfulMFAChallenge() - val callback = MockAuthenticationCallback() - client.multifactorChallenge("ey30.the-mfa-token.value", null, null) - .start(callback) - ShadowLooper.idleMainLooper() - val request = mockAPI.takeRequest() - assertThat( - request.getHeader("Accept-Language"), Matchers.`is`( - defaultLocale - ) - ) - assertThat(request.path, Matchers.equalTo("/mfa/challenge")) - val body = bodyFromRequest(request) - assertThat(body, Matchers.hasEntry("mfa_token", "ey30.the-mfa-token.value")) - assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) - assertThat(body, Matchers.not(Matchers.hasKey("challenge_type"))) - assertThat(body, Matchers.not(Matchers.hasKey("authenticator_id"))) - assertThat(body, Matchers.not(Matchers.hasKey("scope"))) - assertThat( - callback, AuthenticationCallbackMatcher.hasPayloadOfType( - Challenge::class.java - ) - ) - } - - @Test - public fun shouldStartMFAChallengeWithTypeAndAuthenticator() { - mockAPI.willReturnSuccessfulMFAChallenge() - val callback = MockAuthenticationCallback() - client.multifactorChallenge("ey30.the-mfa-token.value", "oob", "sms|dev_NU1Ofuw3Cw0XCt5x") - .start(callback) - ShadowLooper.idleMainLooper() - val request = mockAPI.takeRequest() - assertThat( - request.getHeader("Accept-Language"), Matchers.`is`( - defaultLocale - ) - ) - assertThat(request.path, Matchers.equalTo("/mfa/challenge")) - val body = bodyFromRequest(request) - assertThat(body, Matchers.hasEntry("mfa_token", "ey30.the-mfa-token.value")) - assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) - assertThat(body, Matchers.hasEntry("challenge_type", "oob")) - assertThat(body, Matchers.hasEntry("authenticator_id", "sms|dev_NU1Ofuw3Cw0XCt5x")) - assertThat(body, Matchers.not(Matchers.hasKey("scope"))) - assertThat( - callback, AuthenticationCallbackMatcher.hasPayloadOfType( - Challenge::class.java - ) - ) - } - @Test public fun shouldLoginWithUserAndPasswordSync() { val jwt = JwtTestUtils.createTestJWT("HS256", mapOf("sub" to "auth0|123456")) @@ -1034,7 +896,7 @@ public class AuthenticationAPIClientTest { ) ) assertThat(request.path, Matchers.equalTo("/dbconnections/signup")) - val body = bodyFromRequest(request) + val body = bodyFromRequest(request) assertThat(body, Matchers.hasEntry("email", SUPPORT_AUTH0_COM)) assertThat(body, Matchers.hasEntry("username", SUPPORT)) assertThat(body, Matchers.hasEntry("password", PASSWORD)) @@ -1058,7 +920,7 @@ public class AuthenticationAPIClientTest { ) ) assertThat(request.path, Matchers.equalTo("/dbconnections/signup")) - val body = bodyFromRequest(request) + val body = bodyFromRequest(request) assertThat(body, Matchers.hasEntry("email", SUPPORT_AUTH0_COM)) assertThat(body, Matchers.hasEntry("username", SUPPORT)) assertThat(body, Matchers.hasEntry("password", PASSWORD)) @@ -1361,7 +1223,7 @@ public class AuthenticationAPIClientTest { ) ) assertThat(request.path, Matchers.equalTo("/dbconnections/signup")) - val body = bodyFromRequest(request) + val body = bodyFromRequest(request) assertThat(body, Matchers.hasEntry("email", SUPPORT_AUTH0_COM)) assertThat(body, Matchers.hasEntry("username", SUPPORT)) assertThat(body, Matchers.hasEntry("password", PASSWORD)) @@ -2380,8 +2242,130 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSsoExchange() { + public fun shouldCustomTokenExchangeWithOptions() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + client.customTokenExchange( + "subject-token-type", + "subject-token", + "org_12345", + actorToken + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_12345")) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldCustomTokenExchangeWithOptionsSyncWithoutOrganization() { + mockAPI.willReturnSuccessfulLogin() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + val credentials = client + .customTokenExchange( + "subject-token-type", + "subject-token", + actorToken = actorToken + ) + .execute() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.not(Matchers.hasKey("organization"))) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitCustomTokenExchangeWithOptions(): Unit = runTest { + mockAPI.willReturnSuccessfulLogin() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + val credentials = client + .customTokenExchange( + "subject-token-type", + "subject-token", + "org_abc", + actorToken + ) + .await() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_abc")) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldCustomTokenExchangeWithoutOptions() { mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + client.customTokenExchange("subject-token-type", "subject-token", "org_12345") + .start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_12345")) + assertThat(body, Matchers.not(Matchers.hasKey("actor_token"))) + assertThat(body, Matchers.not(Matchers.hasKey("actor_token_type"))) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldSsoExchange() { + mockAPI.willReturnSuccessfulSSOExchange() val callback = MockAuthenticationCallback() client.ssoExchange("refresh-token") .start(callback) @@ -2413,7 +2397,7 @@ public class AuthenticationAPIClientTest { @Test public fun shouldSsoExchangeSync() { - mockAPI.willReturnSuccessfulLogin() + mockAPI.willReturnSuccessfulSSOExchange() val sessionTransferCredentials = client.ssoExchange("refresh-token") .execute() val request = mockAPI.takeRequest() @@ -2437,7 +2421,7 @@ public class AuthenticationAPIClientTest { @Test @ExperimentalCoroutinesApi public fun shouldAwaitSsoExchange(): Unit = runTest { - mockAPI.willReturnSuccessfulLogin() + mockAPI.willReturnSuccessfulSSOExchange() val ssoCredentials = client .ssoExchange("refresh-token") .await() @@ -3096,7 +3080,7 @@ public class AuthenticationAPIClientTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) - mockAPI.willReturnSuccessfulLogin() + mockAPI.willReturnSuccessfulSSOExchange() val callback = MockAuthenticationCallback() client.useDPoP(mockContext).ssoExchange("refresh-token") @@ -3132,8 +3116,8 @@ public class AuthenticationAPIClientTest { assertThat(exception.cause, Matchers.instanceOf(DPoPException::class.java)) } - private fun bodyFromRequest(request: RecordedRequest): Map { - val mapType = object : TypeToken?>() {}.type + private inline fun bodyFromRequest(request: RecordedRequest): Map { + val mapType = object : TypeToken>() {}.type return gson.fromJson(request.body.readUtf8(), mapType) } diff --git a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt index 8fec80b5f..b528d2878 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -1,30 +1,44 @@ package com.auth0.android.authentication +import android.content.Context import com.auth0.android.Auth0 import com.auth0.android.authentication.mfa.MfaApiClient import com.auth0.android.authentication.mfa.MfaEnrollmentType +import com.auth0.android.authentication.mfa.MfaException.MfaChallengeException +import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException +import com.auth0.android.authentication.mfa.MfaException.MfaListAuthenticatorsException +import com.auth0.android.authentication.mfa.MfaException.MfaVerifyException import com.auth0.android.authentication.mfa.MfaVerificationType -import com.auth0.android.authentication.mfa.MfaException.* +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.internal.ThreadSwitcherShadow import com.auth0.android.result.Authenticator import com.auth0.android.result.Challenge import com.auth0.android.result.Credentials import com.auth0.android.result.EnrollmentChallenge -import com.auth0.android.result.MfaEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge -import com.auth0.android.util.CallbackMatcher import com.auth0.android.util.MockCallback import com.auth0.android.util.SSLTestUtils import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.instanceOf +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue import org.junit.After import org.junit.Assert.assertThrows import org.junit.Before @@ -43,6 +57,8 @@ public class MfaApiClientTest { private lateinit var auth0: Auth0 private lateinit var mfaClient: MfaApiClient private lateinit var gson: Gson + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context @Before public fun setUp(): Unit { @@ -53,11 +69,16 @@ public class MfaApiClientTest { auth0.networkingClient = SSLTestUtils.testClient mfaClient = MfaApiClient(auth0, MFA_TOKEN) gson = GsonBuilder().serializeNulls().create() + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore } @After public fun tearDown(): Unit { mockServer.shutdown() + DPoPUtil.keyStore = DPoPKeyStore() } private fun enqueueMockResponse(json: String, statusCode: Int = 200): Unit { @@ -69,7 +90,11 @@ public class MfaApiClientTest { ) } - private fun enqueueErrorResponse(error: String, description: String, statusCode: Int = 400): Unit { + private fun enqueueErrorResponse( + error: String, + description: String, + statusCode: Int = 400 + ): Unit { val json = """{"error": "$error", "error_description": "$description"}""" enqueueMockResponse(json, statusCode) } @@ -86,38 +111,227 @@ public class MfaApiClientTest { assertThat(client, `is`(notNullValue())) } + @Test + public fun shouldAttachDpopHeaderOnVerifyWhenDpopEnabledViaUseDPoP(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + dpopClient.verify(MfaVerificationType.Otp("123456")).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldAttachDpopHeaderOnVerifyWhenDpopInheritedFromAuthClient(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).mfaClient(MFA_TOKEN) + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + dpopClient.verify(MfaVerificationType.Otp("123456")).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnVerifyWhenDpopDisabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + mfaClient.verify(MfaVerificationType.Otp("123456")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnChallengeWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse("""{"challenge_type": "oob", "oob_code": "oob_123"}""") + + dpopClient.challenge("sms|dev_123").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/challenge")) + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnEnrollWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse("""{"id": "sms|dev_123", "auth_session": "session_abc"}""") + + dpopClient.enroll(MfaEnrollmentType.Phone("+12025550135")).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/associate")) + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnGetAuthenticatorsWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse("""[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""") + + dpopClient.getAuthenticators(listOf("phone")).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/authenticators")) + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + @Test + public fun shouldWrapDPoPExceptionAsMfaVerifyException(): Unit { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenThrow(DPoPException.KEY_PAIR_NOT_FOUND) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + + val exception = assertThrows(MfaVerifyException::class.java) { + runTest { + dpopClient.verify(MfaVerificationType.Otp("123456")).await() + } + } + assertThat(exception.getCode(), `is`("dpop_error")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldAttachDpopHeaderOnVerifyWhenDpopEnabledWithCallback(): Unit { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + val callback = MockCallback() + dpopClient.verify(MfaVerificationType.Otp("123456")).start(callback) + ShadowLooper.idleMainLooper() + + assertThat(callback.getPayload(), `is`(notNullValue())) + assertThat(callback.getPayload().accessToken, `is`(ACCESS_TOKEN)) + assertThat(callback.getError(), `is`(nullValue())) + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldWrapDPoPExceptionAsMfaVerifyExceptionWithCallback(): Unit { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenThrow(DPoPException.KEY_PAIR_NOT_FOUND) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + + val callback = MockCallback() + dpopClient.verify(MfaVerificationType.Otp("123456")).start(callback) + ShadowLooper.idleMainLooper() + + assertThat(callback.getPayload(), `is`(nullValue())) + assertThat(callback.getError(), `is`(notNullValue())) + assertThat(callback.getError().getCode(), `is`("dpop_error")) + assertThat(mockServer.requestCount, `is`(0)) + } + + + @Test + public fun shouldIncludeAuth0ClientHeaderInGetAuthenticators(): Unit = runTest { + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" + enqueueMockResponse(json) + + mfaClient.getAuthenticators(listOf("phone")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInEnroll(): Unit = runTest { + val json = """{"id": "sms|dev_123", "auth_session": "session_abc"}""" + enqueueMockResponse(json) + + mfaClient.enroll(MfaEnrollmentType.Phone("+12025550135")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInChallenge(): Unit = runTest { + val json = """{"challenge_type": "oob", "oob_code": "oob_123"}""" + enqueueMockResponse(json) + + mfaClient.challenge("sms|dev_123").await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInVerify(): Unit = runTest { + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + enqueueMockResponse(json) + + mfaClient.verify(MfaVerificationType.Otp("123456")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } @Test public fun shouldGetAuthenticatorsSuccess(): Unit = runTest { val json = """[ - {"id": "sms|dev_123", "type": "oob", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, - {"id": "totp|dev_456", "type": "otp", "authenticator_type": "otp", "active": true} + {"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, + {"id": "voice|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "voice"}, + {"id": "recovery|dev_789", "type": "recovery-code", "authenticator_type": "recovery-code", "active": true}, + {"id": "email|dev_456", "type": "email", "authenticator_type": "oob", "active": true, "oob_channel": "email"} ]""" enqueueMockResponse(json) - val authenticators = mfaClient.getAuthenticators(listOf("oob", "otp")).await() + val authenticators = mfaClient.getAuthenticators(listOf("phone", "email")).await() - assertThat(authenticators, hasSize(2)) + assertThat(authenticators, hasSize(3)) assertThat(authenticators[0].id, `is`("sms|dev_123")) - assertThat(authenticators[0].type, `is`("oob")) - assertThat(authenticators[1].id, `is`("totp|dev_456")) - assertThat(authenticators[1].type, `is`("otp")) + assertThat(authenticators[0].type, `is`("phone")) + assertThat(authenticators[1].id, `is`("voice|dev_123")) + assertThat(authenticators[1].type, `is`("phone")) + assertThat(authenticators[2].id, `is`("email|dev_456")) + assertThat(authenticators[2].type, `is`("email")) } @Test public fun shouldFilterAuthenticatorsByFactorsAllowed(): Unit = runTest { val json = """[ - {"id": "sms|dev_123", "type": "oob", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, - {"id": "totp|dev_456", "type": "otp", "authenticator_type": "otp", "active": true}, + {"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, + {"id": "email|dev_456", "type": "email", "authenticator_type": "oob", "active": true, "oob_channel": "email"}, {"id": "recovery|dev_789", "type": "recovery-code", "authenticator_type": "recovery-code", "active": true} ]""" enqueueMockResponse(json) - val authenticators = mfaClient.getAuthenticators(listOf("otp")).await() + val authenticators = mfaClient.getAuthenticators(listOf("recovery-code")).await() assertThat(authenticators, hasSize(1)) - assertThat(authenticators[0].id, `is`("totp|dev_456")) - assertThat(authenticators[0].type, `is`("otp")) + assertThat(authenticators[0].id, `is`("recovery|dev_789")) + assertThat(authenticators[0].type, `is`("recovery-code")) } @Test @@ -133,10 +347,10 @@ public class MfaApiClientTest { @Test public fun shouldIncludeAuthorizationHeaderInGetAuthenticators(): Unit = runTest { - val json = """[{"id": "sms|dev_123", "type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" enqueueMockResponse(json) - mfaClient.getAuthenticators(listOf("oob")).await() + mfaClient.getAuthenticators(listOf("phone")).await() val request = mockServer.takeRequest() assertThat(request.getHeader("Authorization"), `is`("Bearer $MFA_TOKEN")) @@ -150,7 +364,7 @@ public class MfaApiClientTest { val exception = assertThrows(MfaListAuthenticatorsException::class.java) { runTest { - mfaClient.getAuthenticators(listOf("oob")).await() + mfaClient.getAuthenticators(listOf("phone")).await() } } assertThat(exception.getCode(), `is`("access_denied")) @@ -161,7 +375,7 @@ public class MfaApiClientTest { @Test public fun shouldReturnEmptyListWhenNoMatchingFactors(): Unit = runTest { val json = """[ - {"id": "sms|dev_123", "type": "oob", "active": true} + {"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true} ]""" enqueueMockResponse(json) @@ -266,11 +480,13 @@ public class MfaApiClientTest { @Test public fun shouldEnrollOtpSuccess(): Unit = runTest { + // Real /mfa/associate TOTP response shape: authenticator_type + secret + barcode_uri + // + recovery_codes. It does NOT contain id, auth_session or manual_input_code. val json = """{ - "id": "totp|dev_789", - "auth_session": "session_ghi", + "authenticator_type": "otp", + "secret": "JBSWY3DPEHPK3PXP", "barcode_uri": "otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", - "manual_input_code": "JBSWY3DPEHPK3PXP" + "recovery_codes": ["ABCD1234EFGH5678"] }""" enqueueMockResponse(json) @@ -278,10 +494,14 @@ public class MfaApiClientTest { assertThat(challenge, `is`(instanceOf(TotpEnrollmentChallenge::class.java))) val totpChallenge = challenge as TotpEnrollmentChallenge - assertThat(totpChallenge.id, `is`("totp|dev_789")) - assertThat(totpChallenge.authSession, `is`("session_ghi")) + assertThat(totpChallenge.authenticatorType, `is`("otp")) + assertThat(totpChallenge.secret, `is`("JBSWY3DPEHPK3PXP")) assertThat(totpChallenge.barcodeUri, containsString("otpauth://")) - assertThat(totpChallenge.manualInputCode, `is`("JBSWY3DPEHPK3PXP")) + assertThat(totpChallenge.recoveryCodes, `is`(listOf("ABCD1234EFGH5678"))) + // Fields not returned by /mfa/associate must be null (not force-unwrapped non-null). + assertThat(totpChallenge.id, `is`(nullValue())) + assertThat(totpChallenge.authSession, `is`(nullValue())) + assertThat(totpChallenge.manualInputCode, `is`(nullValue())) } @Test @@ -436,7 +656,8 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOtpWithCorrectGrantType(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) mfaClient.verify(MfaVerificationType.Otp("123456")).await() @@ -500,10 +721,12 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOobWithoutBindingCodeSuccess(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) - val credentials = mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await() + val credentials = + mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await() assertThat(credentials, `is`(notNullValue())) assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) @@ -511,10 +734,12 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOobWithCorrectParameters(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) - mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123", bindingCode = "654321")).await() + mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123", bindingCode = "654321")) + .await() val request = mockServer.takeRequest() assertThat(request.path, `is`("/oauth/token")) @@ -530,7 +755,8 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOobWithoutBindingCodeInRequest(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await() @@ -565,7 +791,8 @@ public class MfaApiClientTest { }""" enqueueMockResponse(json) - val credentials = mfaClient.verify(MfaVerificationType.RecoveryCode("OLD_RECOVERY_CODE")).await() + val credentials = + mfaClient.verify(MfaVerificationType.RecoveryCode("OLD_RECOVERY_CODE")).await() assertThat(credentials, `is`(notNullValue())) assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) @@ -574,7 +801,8 @@ public class MfaApiClientTest { @Test public fun shouldVerifyRecoveryCodeWithCorrectParameters(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) mfaClient.verify(MfaVerificationType.RecoveryCode("RECOVERY_123")).await() @@ -620,12 +848,12 @@ public class MfaApiClientTest { @Test public fun shouldGetAuthenticatorsWithCallback(): Unit { - val json = """[{"id": "sms|dev_123", "authenticator_type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" enqueueMockResponse(json) val callback = MockCallback, MfaListAuthenticatorsException>() - mfaClient.getAuthenticators(listOf("oob")) + mfaClient.getAuthenticators(listOf("phone")) .start(callback) ShadowLooper.idleMainLooper() @@ -671,7 +899,8 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOtpWithCallback(): Unit { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) val callback = MockCallback() @@ -763,8 +992,10 @@ public class MfaApiClientTest { private companion object { private const val CLIENT_ID = "CLIENT_ID" private const val MFA_TOKEN = "MFA_TOKEN_123" - private const val ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" - private const val ID_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Gfx6VO9tcxwk6xqx9yYzSfebfeakZp5JYIgP_edcw_A" + private const val ACCESS_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + private const val ID_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Gfx6VO9tcxwk6xqx9yYzSfebfeakZp5JYIgP_edcw_A" private const val REFRESH_TOKEN = "REFRESH_TOKEN" } } diff --git a/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt new file mode 100644 index 000000000..ded7730cb --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt @@ -0,0 +1,284 @@ +package com.auth0.android.authentication + +import android.content.Context +import com.auth0.android.Auth0 +import com.auth0.android.authentication.passwordless.DeliveryMethod +import com.auth0.android.authentication.passwordless.PasswordlessClient +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey +import com.auth0.android.request.internal.ThreadSwitcherShadow +import com.auth0.android.result.Credentials +import com.auth0.android.result.PasswordlessChallenge +import com.auth0.android.util.SSLTestUtils +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.hasEntry +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ThreadSwitcherShadow::class]) +@OptIn(ExperimentalCoroutinesApi::class) +public class PasswordlessClientTest { + + private lateinit var mockServer: MockWebServer + private lateinit var auth0: Auth0 + private lateinit var passwordlessClient: PasswordlessClient + private lateinit var gson: Gson + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context + + @Before + public fun setUp() { + mockServer = SSLTestUtils.createMockWebServer() + mockServer.start() + val domain = mockServer.url("/").toString() + auth0 = Auth0.getInstance(CLIENT_ID, domain, domain) + auth0.networkingClient = SSLTestUtils.testClient + passwordlessClient = PasswordlessClient(auth0) + gson = GsonBuilder().serializeNulls().create() + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore + } + + @After + public fun tearDown() { + mockServer.shutdown() + } + + private fun enqueueMockResponse(json: String, statusCode: Int = 200) { + mockServer.enqueue( + MockResponse() + .setResponseCode(statusCode) + .addHeader("Content-Type", "application/json") + .setBody(json) + ) + } + + private fun enqueueErrorResponse(error: String, description: String, statusCode: Int = 400) { + enqueueMockResponse("""{"error": "$error", "error_description": "$description"}""", statusCode) + } + + private inline fun bodyFromRequest(request: RecordedRequest): Map { + val mapType = object : TypeToken>() {}.type + return gson.fromJson(request.body.readUtf8(), mapType) + } + + @Test + public fun shouldCreateClient() { + assertThat(PasswordlessClient(auth0), `is`(notNullValue())) + } + + @Test + public fun shouldChallengeWithEmailHitOtpChallengeWithCorrectParams(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + val challenge = passwordlessClient + .challengeWithEmail("user@example.com", CONNECTION, allowSignup = true) + .await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/otp/challenge")) + assertThat(request.method, `is`("POST")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("connection", CONNECTION)) + assertThat(body, hasEntry("email", "user@example.com")) + assertThat(body, hasEntry("allow_signup", "true")) + assertThat(challenge.authSession, `is`("session_abc")) + } + + @Test + public fun shouldChallengeWithEmailDefaultAllowSignupFalse(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() + + val body = bodyFromRequest(mockServer.takeRequest()) + assertThat(body, hasEntry("allow_signup", "false")) + } + + @Test + public fun shouldChallengeWithPhoneNumberHitOtpChallengeWithCorrectParams(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + val challenge = passwordlessClient.challengeWithPhoneNumber( + "+15555550123", CONNECTION, DeliveryMethod.VOICE, allowSignup = true + ).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/otp/challenge")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("connection", CONNECTION)) + assertThat(body, hasEntry("phone_number", "+15555550123")) + assertThat(body, hasEntry("delivery_method", "voice")) + assertThat(body, hasEntry("allow_signup", "true")) + assertThat(challenge.authSession, `is`("session_abc")) + } + + @Test + public fun shouldChallengeWithPhoneNumberDefaultDeliveryMethodText(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithPhoneNumber("+15555550123", CONNECTION).await() + + val body = bodyFromRequest(mockServer.takeRequest()) + assertThat(body, hasEntry("delivery_method", "text")) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInChallenge(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() + + assertThat(mockServer.takeRequest().getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldPropagateChallengeApiError() { + enqueueErrorResponse("invalid_connection", "Connection does not exist", 400) + + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_connection")) + } + + @Test + public fun shouldFailChallengeWithBlankEmailWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("email is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldFailChallengeWithBlankConnectionWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("user@example.com", "").await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("connection is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldFailChallengeWithBlankPhoneWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithPhoneNumber("", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("phone_number is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldLoginWithOtpHitOauthTokenWithCorrectParams(): Unit = runTest { + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.method, `is`("POST")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("grant_type", "http://auth0.com/oauth/grant-type/passwordless/otp")) + assertThat(body, hasEntry("auth_session", "session_abc")) + assertThat(body, hasEntry("otp", "123456")) + } + + @Test + public fun shouldLoginWithOtpReturnCredentials(): Unit = runTest { + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + val credentials = passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) + } + + @Test + public fun shouldPropagateLoginWithOtpApiError() { + enqueueErrorResponse("invalid_grant", "Invalid or expired code", 403) + + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "000000").await() } + } + assertThat(exception.getCode(), `is`("invalid_grant")) + } + + @Test + public fun shouldFailLoginWithBlankOtpWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "").await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("otp is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldAttachDpopHeaderOnLoginWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).passwordlessClient() + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + dpopClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnChallengeWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).passwordlessClient() + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + dpopClient.challengeWithEmail("user@example.com", CONNECTION).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + private companion object { + private const val CLIENT_ID = "CLIENT_ID" + private const val CONNECTION = "Username-Password-Authentication" + private const val ACCESS_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.sig" + private const val ID_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.sig" + } +} diff --git a/auth0/src/test/java/com/auth0/android/authentication/request/ProfileRequestTest.java b/auth0/src/test/java/com/auth0/android/authentication/request/ProfileRequestTest.java index 6528d46da..3aa931a59 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/request/ProfileRequestTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/request/ProfileRequestTest.java @@ -7,7 +7,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/auth0/src/test/java/com/auth0/android/authentication/request/SignUpRequestTest.java b/auth0/src/test/java/com/auth0/android/authentication/request/SignUpRequestTest.java index 2718fcec6..91caf08c0 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/request/SignUpRequestTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/request/SignUpRequestTest.java @@ -23,7 +23,7 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 4eee35709..57b7dee0c 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -3,7 +3,12 @@ package com.auth0.android.authentication.storage import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.storage.BaseCredentialsManager.Companion.DEFAULT_MIN_TTL import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt @@ -16,16 +21,6 @@ import com.auth0.android.result.SSOCredentialsMock import com.auth0.android.result.toAPICredentials import com.auth0.android.util.Clock import com.google.gson.Gson -import com.nhaarman.mockitokotlin2.KArgumentCaptor -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions -import com.nhaarman.mockitokotlin2.verifyZeroInteractions import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -33,6 +28,7 @@ import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is import org.hamcrest.core.IsInstanceOf +import org.junit.After import org.junit.Assert import org.junit.Assert.assertThrows import org.junit.Before @@ -44,6 +40,17 @@ import org.mockito.ArgumentMatchers import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import java.util.Date import java.util.concurrent.Executor @@ -74,6 +81,10 @@ public class CredentialsManagerTest { @Mock private lateinit var jwtDecoder: JWTDecoder + private lateinit var mockDPoPKeyStore: DPoPKeyStore + private val fakePublicKey = FakeECPublicKey() + private val fakePrivateKey = FakeECPrivateKey() + private val serialExecutor = Executor { runnable -> runnable.run() } private val credentialsCaptor: KArgumentCaptor = argumentCaptor() @@ -92,6 +103,11 @@ public class CredentialsManagerTest { @Before public fun setUp() { MockitoAnnotations.openMocks(this) + mockDPoPKeyStore = mock() + DPoPUtil.keyStore = mockDPoPKeyStore + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + Mockito.`when`(client.executor).thenReturn(serialExecutor) + val credentialsManager = CredentialsManager(client, storage, jwtDecoder, serialExecutor) manager = Mockito.spy(credentialsManager) //Needed to test expiration verification @@ -135,6 +151,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", expirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", expirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -158,6 +175,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", accessTokenExpirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -182,6 +200,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", accessTokenExpirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -206,6 +225,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", expirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", expirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -252,10 +272,10 @@ public class CredentialsManagerTest { @Test public fun shouldNotSaveIfTheSSOCredentialsHasNoRefreshToken() { - verifyZeroInteractions(storage) + verifyNoMoreInteractions(storage) val ssoCredentials = SSOCredentialsMock.create( "accessToken", "identityToken", - "issuedTokenType", "tokenType", null, 60 + "issuedTokenType", "tokenType", null, Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) ) manager.saveSsoCredentials(ssoCredentials) } @@ -264,8 +284,12 @@ public class CredentialsManagerTest { public fun shouldNotSaveIfTheNewSSOCredentialRefreshTokenIsSameAsTheExistingOne() { verifyNoMoreInteractions(storage) val ssoCredentials = SSOCredentialsMock.create( - "accessToken", "identityToken", - "issuedTokenType", "tokenType", "refresh_token", 60 + "accessToken", + "identityToken", + "issuedTokenType", + "tokenType", + "refresh_token", + Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) ) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")) .thenReturn("refresh_token") @@ -277,8 +301,12 @@ public class CredentialsManagerTest { public fun shouldSaveTheRefreshTokenIfTheNewSSOCredentialsRefreshTokenIsNotSameAsTheOldOne() { verifyNoMoreInteractions(storage) val ssoCredentials = SSOCredentialsMock.create( - "accessToken", "identityToken", - "issuedTokenType", "tokenType", "refresh_token", 60 + "accessToken", + "identityToken", + "issuedTokenType", + "tokenType", + "refresh_token", + Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) ) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")) .thenReturn("refresh-token") @@ -308,6 +336,7 @@ public class CredentialsManagerTest { .thenReturn("refresh_token_old") Mockito.`when`(client.ssoExchange("refresh_token_old")) .thenReturn(SSOCredentialsRequest) + val ssoExpiresAt = Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) Mockito.`when`(SSOCredentialsRequest.execute()).thenReturn( SSOCredentialsMock.create( "web-sso-token", @@ -315,7 +344,7 @@ public class CredentialsManagerTest { "issued-token-type", "token-type", "refresh-token", - 60 + ssoExpiresAt ) ) manager.getSsoCredentials(ssoCallback) @@ -328,7 +357,7 @@ public class CredentialsManagerTest { MatcherAssert.assertThat(credentials.tokenType, Is.`is`("token-type")) MatcherAssert.assertThat(credentials.issuedTokenType, Is.`is`("issued-token-type")) MatcherAssert.assertThat(credentials.refreshToken, Is.`is`("refresh-token")) - MatcherAssert.assertThat(credentials.expiresIn, Is.`is`(60)) + MatcherAssert.assertThat(credentials.expiresAt, Is.`is`(ssoExpiresAt)) verify(storage).store("com.auth0.refresh_token", credentials.refreshToken) } @@ -404,6 +433,7 @@ public class CredentialsManagerTest { .thenReturn("refresh_token_old") Mockito.`when`(client.ssoExchange("refresh_token_old")) .thenReturn(SSOCredentialsRequest) + val ssoExpiresAt = Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) Mockito.`when`(SSOCredentialsRequest.execute()).thenReturn( SSOCredentialsMock.create( "web-sso-token", @@ -411,7 +441,7 @@ public class CredentialsManagerTest { "issued-token-type", "token-type", "refresh-token", - 60 + ssoExpiresAt ) ) val credentials = manager.awaitSsoCredentials() @@ -420,7 +450,7 @@ public class CredentialsManagerTest { MatcherAssert.assertThat(credentials.tokenType, Is.`is`("token-type")) MatcherAssert.assertThat(credentials.issuedTokenType, Is.`is`("issued-token-type")) MatcherAssert.assertThat(credentials.refreshToken, Is.`is`("refresh-token")) - MatcherAssert.assertThat(credentials.expiresIn, Is.`is`(60)) + MatcherAssert.assertThat(credentials.expiresAt, Is.`is`(ssoExpiresAt)) verify(storage).store("com.auth0.refresh_token", credentials.refreshToken) } @@ -433,7 +463,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) verify(apiCredentialsCallback).onSuccess(apiCredentialsCaptor.capture()) val retrievedCredentials = apiCredentialsCaptor.firstValue @@ -482,7 +513,10 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) // RefreshToken should not be replaced verify(storage).store("com.auth0.refresh_token", "refreshToken") - verify(storage).store("audience::newScope", gson.toJson(renewedCredentials.toAPICredentials())) + verify(storage).store( + "audience::newScope", + gson.toJson(renewedCredentials.toAPICredentials()) + ) // Verify the returned credentials are the latest val newAPiCredentials = apiCredentialsCaptor.firstValue MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) @@ -500,7 +534,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( client.renewAuth("refreshToken", "audience", "scope") @@ -542,7 +577,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( client.renewAuth("refreshToken", "audience", "scope") @@ -557,7 +593,12 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "scope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "scope", minTtl = 10, callback = apiCredentialsCallback) + manager.getApiCredentials( + "audience", + "scope", + minTtl = 10, + callback = apiCredentialsCallback + ) verify(apiCredentialsCallback).onSuccess( apiCredentialsCaptor.capture() ) @@ -582,7 +623,7 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( - client.renewAuth("refreshToken", "audience","newScope") + client.renewAuth("refreshToken", "audience", "newScope") ).thenReturn(request) val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) val jwtMock = mock() @@ -602,7 +643,10 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) // RefreshToken should be replaced verify(storage).store("com.auth0.refresh_token", "newRefreshToken") - verify(storage).store("audience::newScope", gson.toJson(renewedCredentials.toAPICredentials())) + verify(storage).store( + "audience::newScope", + gson.toJson(renewedCredentials.toAPICredentials()) + ) // Verify the returned credentials are the latest val newAPiCredentials = apiCredentialsCaptor.firstValue MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) @@ -630,7 +674,12 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", minTtl = 1, callback = apiCredentialsCallback) + manager.getApiCredentials( + "audience", + "newScope", + minTtl = 1, + callback = apiCredentialsCallback + ) verify(apiCredentialsCallback).onFailure( exceptionCaptor.capture() ) @@ -651,7 +700,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) val retrievedCredentials = manager.awaitApiCredentials("audience", "scope") MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) Assert.assertEquals(retrievedCredentials.accessToken, apiCredentials.accessToken) @@ -668,12 +718,12 @@ public class CredentialsManagerTest { Mockito.`when`( client.renewAuth("refresh_token", "audience") ).thenReturn(request) - val newDate = Date(CredentialsMock.CURRENT_TIME_MS + 1 * 1000) + val newDate = Date(CredentialsMock.CURRENT_TIME_MS + (DEFAULT_MIN_TTL + 10) * 1000L) val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) val renewedCredentials = - Credentials("newId", "newAccess", "newType",null, newDate, "newScope") + Credentials("newId", "newAccess", "newType", null, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) val retrievedCredentials = manager.awaitApiCredentials("audience") MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) @@ -912,7 +962,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -970,7 +1020,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1027,7 +1077,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1086,7 +1136,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1144,7 +1194,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") //// Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1252,7 +1302,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") //// Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1475,13 +1525,7 @@ public class CredentialsManagerTest { @Test public fun shouldClearCredentials() { manager.clearCredentials() - verify(storage).remove("com.auth0.id_token") - verify(storage).remove("com.auth0.access_token") - verify(storage).remove("com.auth0.refresh_token") - verify(storage).remove("com.auth0.token_type") - verify(storage).remove("com.auth0.expires_at") - verify(storage).remove("com.auth0.scope") - verify(storage).remove("com.auth0.cache_expires_at") + verify(storage).removeAll() verifyNoMoreInteractions(storage) } @@ -1494,6 +1538,12 @@ public class CredentialsManagerTest { verifyNoMoreInteractions(storage) } + @Test + public fun shouldClearAllCredentials() { + manager.clearAll() + verify(storage).removeAll() + } + @Test public fun shouldSaveApiCredentialsWithScopeAsKey() { val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS @@ -1766,6 +1816,117 @@ public class CredentialsManagerTest { MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true)) } + @Test + public fun shouldNotHaveCredentialsWhenSessionCeilingReachedEvenWithRefreshToken() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Access token still valid and a refresh token is present, so absent the ceiling this would + // report valid credentials; the breached session_expiry ceiling must override that. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + Assert.assertFalse(manager.hasValidCredentials()) + } + + @Test + public fun shouldRenewCredentialsViaCallbackWhenTokenExpiresWithinDefaultMinTtl() { + // Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s) + val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`( + client.renewAuth("refreshToken") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + val renewedCredentials = + Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + // Use no-arg getCredentials which now uses DEFAULT_MIN_TTL + manager.getCredentials(callback) + verify(callback).onSuccess( + credentialsCaptor.capture() + ) + // Verify renewal was triggered (client.renewAuth was called) + verify(client).renewAuth("refreshToken") + val retrievedCredentials = credentialsCaptor.firstValue + MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(retrievedCredentials.idToken, Is.`is`("newId")) + MatcherAssert.assertThat(retrievedCredentials.accessToken, Is.`is`("newAccess")) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitRenewedCredentialsWhenTokenExpiresWithinDefaultMinTtl(): Unit = runTest { + // Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s) + val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`( + client.renewAuth("refreshToken") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + val renewedCredentials = + Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + // Use no-arg awaitCredentials which now uses DEFAULT_MIN_TTL + val result = manager.awaitCredentials() + // Verify renewal was triggered + verify(client).renewAuth("refreshToken") + MatcherAssert.assertThat(result, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(result.idToken, Is.`is`("newId")) + MatcherAssert.assertThat(result.accessToken, Is.`is`("newAccess")) + } + + @Test + public fun shouldNotHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlAndNoRefreshToken() { + // Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), and no refresh token + val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000 + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn(null) + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + // No-arg hasValidCredentials now uses DEFAULT_MIN_TTL, so token expiring in 30s is invalid + Assert.assertFalse(manager.hasValidCredentials()) + } + + @Test + public fun shouldHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlButRefreshTokenAvailable() { + // Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), but refresh token is available + val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000 + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + // Even though token expires within DEFAULT_MIN_TTL, refresh token makes it valid + MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true)) + } + @Test public fun shouldNotHaveCredentialsWhenAccessTokenAndIdTokenAreMissing() { Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn(null) @@ -1808,7 +1969,7 @@ public class CredentialsManagerTest { //now, update the clock and retry manager.setClock(object : Clock { override fun getCurrentTimeMillis(): Long { - return CredentialsMock.CURRENT_TIME_MS - 1000 + return CredentialsMock.CURRENT_TIME_MS - (DEFAULT_MIN_TTL * 1000 + 1000) } }) MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true)) @@ -1825,7 +1986,6 @@ public class CredentialsManagerTest { }) } - @Test public fun shouldAddParametersToRequest() { Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") @@ -1959,7 +2119,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -1975,10 +2136,10 @@ public class CredentialsManagerTest { ) ) val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) - + MatcherAssert.assertThat(mfaRequiredException.isMultifactorRequired, Is.`is`(true)) MatcherAssert.assertThat(mfaRequiredException.getCode(), Is.`is`("mfa_required")) - + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) manager.getCredentials(callback) @@ -1988,11 +2149,20 @@ public class CredentialsManagerTest { MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) - - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload, + Is.`is`(Matchers.notNullValue()) + ) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("test-mfa-token-12345")) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(2)) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, + Is.`is`(Matchers.notNullValue()) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, + Is.`is`(2) + ) } @Test @@ -2003,7 +2173,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2028,9 +2199,18 @@ public class CredentialsManagerTest { val exception = exceptionCaptor.firstValue MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("enroll-mfa-token")) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(3)) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.nullValue())) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, + Is.`is`(Matchers.notNullValue()) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, + Is.`is`(3) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, + Is.`is`(Matchers.nullValue()) + ) } @Test @@ -2041,7 +2221,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2059,7 +2240,10 @@ public class CredentialsManagerTest { verify(callback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue - MatcherAssert.assertThat(exception.message, Matchers.containsString("processing the request")) + MatcherAssert.assertThat( + exception.message, + Matchers.containsString("processing the request") + ) MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.nullValue())) MatcherAssert.assertThat(exception.mfaToken, Is.`is`(Matchers.nullValue())) } @@ -2073,7 +2257,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2095,7 +2280,10 @@ public class CredentialsManagerTest { } MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload, + Is.`is`(Matchers.notNullValue()) + ) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("await-mfa-token-12345")) } @@ -2107,7 +2295,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2123,19 +2312,625 @@ public class CredentialsManagerTest { verify(callback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue - + MatcherAssert.assertThat(exception.cause, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.cause, IsInstanceOf.instanceOf(AuthenticationException::class.java)) - + MatcherAssert.assertThat( + exception.cause, + IsInstanceOf.instanceOf(AuthenticationException::class.java) + ) + val causeException = exception.cause as AuthenticationException MatcherAssert.assertThat(causeException.getCode(), Is.`is`("mfa_required")) MatcherAssert.assertThat(causeException.isMultifactorRequired, Is.`is`(true)) - MatcherAssert.assertThat(causeException.getDescription(), Is.`is`("MFA is required for this action")) + MatcherAssert.assertThat( + causeException.getDescription(), + Is.`is`("MFA is required for this action") + ) + } + + @Test + public fun shouldStoreDPoPThumbprintWhenCredentialsTypeIsDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), ArgumentMatchers.anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldStoreDPoPThumbprintWhenIsDPoPEnabledIsTrue() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), ArgumentMatchers.anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldRemoveDPoPThumbprintForBearerCredentialsWithoutDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + } + + @Test + public fun shouldRemoveDPoPThumbprintWhenNoKeyPairExists() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPNotConfiguredWhenClientNotSetup() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat( + exception, + Is.`is`(CredentialsManagerException.DPOP_NOT_CONFIGURED) + ) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMismatchWhenThumbprintsDontMatch() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("old-thumbprint-from-previous-key") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISMATCH)) + } + + @Test + public fun shouldBackfillDPoPThumbprintForMigrationScenario() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // No stored thumbprint (migration scenario) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")).thenReturn(null) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + val renewedCredentials = + Credentials("newId", "newAccess", "DPoP", "newRefresh", newDate, "scope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + + manager.getCredentials(callback) + + // Verify thumbprint was backfilled during validation (and also stored again during saveCredentials after renewal) + verify(storage, Mockito.atLeastOnce()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + verify(callback).onSuccess(credentialsCaptor.capture()) + } + + @Test + public fun shouldTriggerDPoPValidationViaStoredThumbprintEvenForBearerTokenType() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("some-thumbprint") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetSsoCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetApiCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + Mockito.`when`(storage.retrieveString("audience::read:data")) + .thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getApiCredentials("audience", "read:data", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldUseApiCredentialTypeForDPoPValidationInsteadOfBaseTokenType() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + Mockito.`when`(storage.retrieveString("audience::read:data")) + .thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getApiCredentials("audience", "read:data", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + // Verifies the outer runCatchingOnExecutor safety net in getCredentials. + // The IllegalStateException is thrown from storage.retrieveString() which is outside any inner + // try/catch block — only the outer catch(Throwable) in runCatchingOnExecutor can handle it. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenThrow(error) + manager.getCredentials(callback) + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Same as above but via the coroutine path (awaitCredentials -> suspendCancellableCoroutine). + // Verifies that the outer catch routes the error through callback.onFailure which then + // resumes the continuation with the exception. + @Test + @ExperimentalCoroutinesApi + public fun shouldCatchUnexpectedExceptionFromStorageOnAwaitCredentials(): Unit = runTest { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenThrow(error) + val exception = assertThrows(CredentialsManagerException::class.java) { + runBlocking { manager.awaitCredentials() } + } + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net in getSsoCredentials. + // The exception is thrown from storage before any inner try/catch is reached. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetSsoCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenThrow(error) + manager.getSsoCredentials(ssoCallback) + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net in getApiCredentials. + // The exception is thrown from storage before any inner try/catch is reached. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetApiCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("audience")).thenThrow(error) + manager.getApiCredentials("audience", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net during API credential renewal. + // NullPointerException from request.execute() is not an AuthenticationException, so it + // escapes the inner catch(AuthenticationException) block. Only the outer catch(Throwable) + // in runCatchingOnExecutor handles it. + @Test + public fun shouldCatchUnexpectedExceptionDuringApiCredentialRenewal() { + val error = NullPointerException("unexpected null") + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(client.renewAuth("refreshToken", "audience", null)).thenReturn(request) + Mockito.`when`(request.execute()).thenThrow(error) + manager.getApiCredentials("audience", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + @After + public fun tearDown() { + DPoPUtil.keyStore = DPoPKeyStore() + } + + // IPSIE session_expiry enforcement + + @Test + public fun shouldFailWithSessionExpiredAndNotRenewWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token already expired so, absent the ceiling, a refresh would otherwise be attempted. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.CURRENT_TIME_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be used past the session ceiling. + verifyNoInteractions(client) + // The breached session must be cleared. + verify(storage).removeAll() + } + + @Test + public fun shouldFailWithSessionExpiredWhenCeilingFallsWithinLeeway() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // 10s in the future, but inside the 30s negative leeway -> treated as expired. + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 10) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + verifyNoInteractions(client) + } + + @Test + public fun shouldReturnCachedCredentialsWhenSessionCeilingNotReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token not yet expired -> cached credentials are served without a refresh. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 100_000) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + verifyNoInteractions(client) + } + + @Test + public fun shouldReturnCredentialsCarryingPinnedSessionExpiresAt() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + val pinnedCeiling = nowSeconds + 100_000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token not yet expired -> cached credentials are served without a refresh. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(pinnedCeiling) + // Pinned at login. The returned credentials must carry this value via stampPinnedSessionExpiry, + // not a value re-decoded from the live ID token. Stubbed after the helper's default null. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat( + credentialsCaptor.firstValue.sessionExpiresAt, + Is.`is`(pinnedCeiling) + ) + } + + @Test + public fun shouldNotEnforceSessionExpiryWhenClaimAndStoredValueAreAbsent() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // No session_expiry claim and no stored ceiling -> existing behavior, no regression. + prepareJwtDecoderMockWithSessionExpiry(null) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldUseStoredSessionExpiryWhenFreshIdTokenOmitsClaim() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.CURRENT_TIME_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // The (refreshed) ID token does not re-emit the claim, but the ceiling persisted at login still applies. + prepareJwtDecoderMockWithSessionExpiry(null) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + verifyNoInteractions(client) + } + + @Test + public fun shouldThrowWhenSavingCredentialsAlreadyPastSessionCeiling() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val jwtMock = mock() + // session_expiry (1000) is at/below iat (2000) -> already expired at creation. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(1000L) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(2000L * 1000)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + val exception = assertThrows(CredentialsManagerException::class.java) { + manager.saveCredentials(credentials) + } + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.SESSION_EXPIRED)) + verify(storage, never()).store(eq("com.auth0.id_token"), ArgumentMatchers.anyString()) + } + + @Test + public fun shouldPersistSessionExpiryWhenSavingCredentials() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val sessionExpiry = (CredentialsMock.ONE_HOUR_AHEAD_MS / 1000) + 100_000 + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(CredentialsMock.ONE_HOUR_AHEAD_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.saveCredentials(credentials) + + verify(storage).store("com.auth0.session_expiry", sessionExpiry) + } + + @Test + public fun shouldFailGetSsoCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for SSO credentials past the ceiling. + verifyNoInteractions(client) + verify(storage).removeAll() + } + + @Test + public fun shouldFailGetApiCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for API credentials past the ceiling. + verifyNoInteractions(client) + verify(storage).removeAll() + } + + @Test + public fun shouldEnforcePinnedSessionExpiryWhenRefreshedIdTokenReEmitsLaterValue() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // The (refreshed) ID token re-emits a *later* session_expiry; it must NOT raise the ceiling. + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 100_000) + // The pinned ceiling from the initial login is already reached. Stubbed after the helper so + // the storage-first lookup sees this value rather than the helper's default null. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // Enforcement honors the pinned value, so the refresh-token grant is never used. + verifyNoInteractions(client) + verify(storage).removeAll() + } + + @Test + public fun shouldNotOverwriteStoredSessionExpiryWhenSavingRefreshedCredentials() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + // A ceiling is already pinned from the initial login. + val pinnedCeiling = (CredentialsMock.ONE_HOUR_AHEAD_MS / 1000) + 100_000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + // The refreshed ID token re-emits a later session_expiry that must be ignored. + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(pinnedCeiling + 100_000) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(CredentialsMock.ONE_HOUR_AHEAD_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.saveCredentials(credentials) + + // The pinned ceiling must not be moved by a refresh-grant claim. + verify(storage, never()).store(eq("com.auth0.session_expiry"), ArgumentMatchers.anyLong()) + } + + private fun prepareJwtDecoderMockWithSessionExpiry(sessionExpiry: Long?) { + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + // No value is pinned in storage by default, so the ceiling resolves from the idToken claim + // above. (Mockito returns 0L for an unstubbed Long?-returning property, which the + // storage-first lookup in isSessionExpired would otherwise consume as a bogus ceiling.) + // Tests that exercise a pinned value stub this key explicitly *after* calling this helper. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) } private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) + // Default to a token without the IPSIE session_expiry claim. Mockito returns 0 (not null) for + // an unstubbed Long?-returning property, which would otherwise trigger a spurious persist. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(null) Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java index d9f84c910..e6b1c3412 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java @@ -1,29 +1,41 @@ package com.auth0.android.authentication.storage; -import android.app.KeyguardManager; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.security.KeyPairGeneratorSpec; import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; import android.text.TextUtils; import android.util.Base64; -import android.util.Log; +import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.stubbing.Answer; -import org.powermock.api.mockito.PowerMockito; -import org.powermock.core.classloader.annotations.PrepareForTest; -import org.powermock.modules.junit4.PowerMockRunner; +import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; -import org.robolectric.util.ReflectionHelpers; import java.io.IOException; import java.math.BigInteger; @@ -53,37 +65,15 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; -import javax.security.auth.x500.X500Principal; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.greaterThan; -import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.when; -import static org.powermock.api.mockito.PowerMockito.doReturn; -import static org.powermock.api.mockito.PowerMockito.doThrow; -import static org.powermock.api.mockito.PowerMockito.mock; -import static org.powermock.api.mockito.PowerMockito.verifyPrivate; /** - * In the rest of the test files we use Mockito as that's enough for most cases. However, - * when Kotlin classes are introduced in the project, Mockito fails to mock them because - * they are final by default. - * The solution is to use the 'mockito-inline' plugin. However, when used in combination - * with Powermock, both configuration files clash and the tests fail. - * The MockMaker needs to be set up only in one place, the Powermock configuration file. - *

- * Read more: https://github.com/powermock/powermock/issues/992#issuecomment-662845804 + * This test class uses MockedStatic for static method mocking (KeyStore, Cipher, KeyGenerator, + * KeyPairGenerator, Base64, TextUtils) and relies on Robolectric shadows for Android SDK + * builder classes like KeyGenParameterSpec.Builder. + * Note: Robolectric 4.x requires SDK 21+ (Android 5.0+). */ -@RunWith(PowerMockRunner.class) -@PrepareForTest({CryptoUtil.class, KeyGenerator.class, TextUtils.class, Build.VERSION.class, Base64.class, Cipher.class, Log.class, KeyStore.class}) +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) public class CryptoUtilTest { private static final String RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; @@ -93,15 +83,21 @@ public class CryptoUtilTest { private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; private static final String ALGORITHM_AES = "AES"; private static final String ALGORITHM_RSA = "RSA"; - private static final int RSA_KEY_SIZE = 2048; - private final Storage storage = PowerMockito.mock(Storage.class); - private final Cipher rsaOaepCipher = PowerMockito.mock(Cipher.class); - private final Cipher rsaPkcs1Cipher = PowerMockito.mock(Cipher.class); - private final Cipher aesCipher = PowerMockito.mock(Cipher.class); - private final KeyStore keyStore = PowerMockito.mock(KeyStore.class); - private final KeyPairGenerator keyPairGenerator = PowerMockito.mock(KeyPairGenerator.class); - private final KeyGenerator keyGenerator = PowerMockito.mock(KeyGenerator.class); + private final Storage storage = Mockito.mock(Storage.class); + private final Cipher rsaOaepCipher = Mockito.mock(Cipher.class); + private final Cipher rsaPkcs1Cipher = Mockito.mock(Cipher.class); + private final Cipher aesCipher = Mockito.mock(Cipher.class); + private final KeyStore keyStore = Mockito.mock(KeyStore.class); + private final KeyPairGenerator keyPairGenerator = Mockito.mock(KeyPairGenerator.class); + private final KeyGenerator keyGenerator = Mockito.mock(KeyGenerator.class); + + private MockedStatic keyStoreMock; + private MockedStatic cipherMock; + private MockedStatic keyGeneratorMock; + private MockedStatic keyPairGeneratorMock; + private MockedStatic base64Mock; + private MockedStatic textUtilsMock; private CryptoUtil cryptoUtil; @@ -117,18 +113,49 @@ public class CryptoUtilTest { @Before public void setUp() throws Exception { - PowerMockito.mockStatic(Log.class); - PowerMockito.mockStatic(TextUtils.class); - PowerMockito.when(TextUtils.isEmpty(nullable(String.class))).then((Answer) invocation -> { - String input = invocation.getArgument(0, String.class); - return input == null || input.isEmpty(); + // Initialize MockedStatic instances for static method mocking + keyStoreMock = Mockito.mockStatic(KeyStore.class); + keyStoreMock.when(() -> KeyStore.getInstance(ANDROID_KEY_STORE)).thenReturn(keyStore); + + cipherMock = Mockito.mockStatic(Cipher.class); + cipherMock.when(() -> Cipher.getInstance(anyString())).thenAnswer((Answer) invocation -> { + String transformation = invocation.getArgument(0, String.class); + if (RSA_TRANSFORMATION.equals(transformation)) { + return rsaOaepCipher; + } else if (OLD_RSA_PKCS1_TRANSFORMATION.equals(transformation)) { + return rsaPkcs1Cipher; + } else if (AES_TRANSFORMATION.equals(transformation)) { + return aesCipher; + } + return null; }); + keyGeneratorMock = Mockito.mockStatic(KeyGenerator.class); + keyGeneratorMock.when(() -> KeyGenerator.getInstance(ALGORITHM_AES)).thenReturn(keyGenerator); + + keyPairGeneratorMock = Mockito.mockStatic(KeyPairGenerator.class); + keyPairGeneratorMock.when(() -> KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE)) + .thenReturn(keyPairGenerator); + + base64Mock = Mockito.mockStatic(Base64.class, Mockito.CALLS_REAL_METHODS); + textUtilsMock = Mockito.mockStatic(TextUtils.class, Mockito.CALLS_REAL_METHODS); + context = mock(Context.class); when(context.getPackageName()).thenReturn(APP_PACKAGE_NAME); cryptoUtil = newCryptoUtilSpy(); } + @After + public void tearDown() { + // Close all MockedStatic instances to prevent memory leaks + if (keyStoreMock != null) keyStoreMock.close(); + if (cipherMock != null) cipherMock.close(); + if (keyGeneratorMock != null) keyGeneratorMock.close(); + if (keyPairGeneratorMock != null) keyPairGeneratorMock.close(); + if (base64Mock != null) base64Mock.close(); + if (textUtilsMock != null) textUtilsMock.close(); + } + /* * GET RSA KEY tests */ @@ -142,199 +169,38 @@ public void shouldThrowWhenRSAKeyAliasIsInvalid() { } @Test - @Config(sdk = 19) - public void shouldNotCreateProtectedRSAKeyPairIfMissingAndLockScreenEnabledOnAPI19() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 19); + public void shouldCreateRSAKeyPairIfMissing() throws Exception { - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - KeyStore.PrivateKeyEntry expectedEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); + KeyStore.PrivateKeyEntry expectedEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); + Mockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); - KeyPairGeneratorSpec spec = PowerMockito.mock(KeyPairGeneratorSpec.class); - KeyPairGeneratorSpec.Builder builder = newKeyPairGeneratorSpecBuilder(spec); - PowerMockito.whenNew(KeyPairGeneratorSpec.Builder.class).withAnyArguments().thenReturn(builder); - - ArgumentCaptor principalCaptor = ArgumentCaptor.forClass(X500Principal.class); - ArgumentCaptor startDateCaptor = ArgumentCaptor.forClass(Date.class); - ArgumentCaptor endDateCaptor = ArgumentCaptor.forClass(Date.class); - - //Set LockScreen as Enabled - KeyguardManager kService = PowerMockito.mock(KeyguardManager.class); - PowerMockito.when(context.getSystemService(Context.KEYGUARD_SERVICE)).thenReturn(kService); - PowerMockito.when(kService.isKeyguardSecure()).thenReturn(true); + ArgumentCaptor specCaptor = ArgumentCaptor.forClass(AlgorithmParameterSpec.class); final KeyStore.PrivateKeyEntry entry = cryptoUtil.getRSAKeyEntry(); - Mockito.verify(builder).setKeySize(2048); - Mockito.verify(builder).setSubject(principalCaptor.capture()); - Mockito.verify(builder).setAlias(KEY_ALIAS); - Mockito.verify(builder).setSerialNumber(BigInteger.ONE); - Mockito.verify(builder).setStartDate(startDateCaptor.capture()); - Mockito.verify(builder).setEndDate(endDateCaptor.capture()); - Mockito.verify(builder, never()).setEncryptionRequired(); - Mockito.verify(keyPairGenerator).initialize(spec); + Mockito.verify(keyPairGenerator).initialize(specCaptor.capture()); Mockito.verify(keyPairGenerator).generateKeyPair(); - assertThat(principalCaptor.getValue(), is(notNullValue())); - assertThat(principalCaptor.getValue().getName(), is(CERTIFICATE_PRINCIPAL)); - - assertThat(startDateCaptor.getValue(), is(notNullValue())); - long diffMillis = startDateCaptor.getValue().getTime() - new Date().getTime(); - long days = TimeUnit.MILLISECONDS.toDays(diffMillis); - assertThat(days, is(0L)); //Date is Today - - assertThat(endDateCaptor.getValue(), is(notNullValue())); - diffMillis = endDateCaptor.getValue().getTime() - new Date().getTime(); - days = TimeUnit.MILLISECONDS.toDays(diffMillis); - assertThat(days, is(greaterThan(25 * 365L))); //Date more than 25 Years in days - - assertThat(entry, is(expectedEntry)); - } - - @Test - @Config(sdk = 21) - public void shouldCreateUnprotectedRSAKeyPairIfMissingAndLockScreenDisabledOnAPI21() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 21); - - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - KeyStore.PrivateKeyEntry expectedEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); - - KeyPairGeneratorSpec spec = PowerMockito.mock(KeyPairGeneratorSpec.class); - KeyPairGeneratorSpec.Builder builder = newKeyPairGeneratorSpecBuilder(spec); - PowerMockito.whenNew(KeyPairGeneratorSpec.Builder.class).withAnyArguments().thenReturn(builder); - - ArgumentCaptor principalCaptor = ArgumentCaptor.forClass(X500Principal.class); - ArgumentCaptor startDateCaptor = ArgumentCaptor.forClass(Date.class); - ArgumentCaptor endDateCaptor = ArgumentCaptor.forClass(Date.class); - - //Set LockScreen as Disabled - KeyguardManager kService = PowerMockito.mock(KeyguardManager.class); - PowerMockito.when(context.getSystemService(Context.KEYGUARD_SERVICE)).thenReturn(kService); - PowerMockito.when(kService.isKeyguardSecure()).thenReturn(false); - PowerMockito.when(kService.createConfirmDeviceCredentialIntent(any(CharSequence.class), any(CharSequence.class))).thenReturn(null); - - final KeyStore.PrivateKeyEntry entry = cryptoUtil.getRSAKeyEntry(); - - Mockito.verify(builder).setKeySize(2048); - Mockito.verify(builder).setSubject(principalCaptor.capture()); - Mockito.verify(builder).setAlias(KEY_ALIAS); - Mockito.verify(builder).setSerialNumber(BigInteger.ONE); - Mockito.verify(builder).setStartDate(startDateCaptor.capture()); - Mockito.verify(builder).setEndDate(endDateCaptor.capture()); - Mockito.verify(builder, never()).setEncryptionRequired(); - Mockito.verify(keyPairGenerator).initialize(spec); - Mockito.verify(keyPairGenerator).generateKeyPair(); - - assertThat(principalCaptor.getValue(), is(notNullValue())); - assertThat(principalCaptor.getValue().getName(), is(CERTIFICATE_PRINCIPAL)); - - assertThat(startDateCaptor.getValue(), is(notNullValue())); - long diffMillis = startDateCaptor.getValue().getTime() - new Date().getTime(); - long days = TimeUnit.MILLISECONDS.toDays(diffMillis); - assertThat(days, is(0L)); //Date is Today - - assertThat(endDateCaptor.getValue(), is(notNullValue())); - diffMillis = endDateCaptor.getValue().getTime() - new Date().getTime(); - days = TimeUnit.MILLISECONDS.toDays(diffMillis); - assertThat(days, is(greaterThan(25 * 365L))); //Date more than 25 Years in days - - assertThat(entry, is(expectedEntry)); - } - - @Test - @Config(sdk = 21) - public void shouldCreateProtectedRSAKeyPairIfMissingAndLockScreenEnabledOnAPI21() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 21); - - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - KeyStore.PrivateKeyEntry expectedEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); - - KeyPairGeneratorSpec spec = PowerMockito.mock(KeyPairGeneratorSpec.class); - KeyPairGeneratorSpec.Builder builder = newKeyPairGeneratorSpecBuilder(spec); - PowerMockito.whenNew(KeyPairGeneratorSpec.Builder.class).withAnyArguments().thenReturn(builder); - - ArgumentCaptor principalCaptor = ArgumentCaptor.forClass(X500Principal.class); - ArgumentCaptor startDateCaptor = ArgumentCaptor.forClass(Date.class); - ArgumentCaptor endDateCaptor = ArgumentCaptor.forClass(Date.class); - - //Set LockScreen as Enabled - KeyguardManager kService = PowerMockito.mock(KeyguardManager.class); - PowerMockito.when(context.getSystemService(Context.KEYGUARD_SERVICE)).thenReturn(kService); - PowerMockito.when(kService.isKeyguardSecure()).thenReturn(true); - PowerMockito.when(kService.createConfirmDeviceCredentialIntent(any(), any())).thenReturn(new Intent()); - - final KeyStore.PrivateKeyEntry entry = cryptoUtil.getRSAKeyEntry(); - - Mockito.verify(builder).setKeySize(2048); - Mockito.verify(builder).setSubject(principalCaptor.capture()); - Mockito.verify(builder).setAlias(KEY_ALIAS); - Mockito.verify(builder).setSerialNumber(BigInteger.ONE); - Mockito.verify(builder).setStartDate(startDateCaptor.capture()); - Mockito.verify(builder).setEndDate(endDateCaptor.capture()); - Mockito.verify(builder).setEncryptionRequired(); - Mockito.verify(keyPairGenerator).initialize(spec); - Mockito.verify(keyPairGenerator).generateKeyPair(); - - assertThat(principalCaptor.getValue(), is(notNullValue())); - assertThat(principalCaptor.getValue().getName(), is(CERTIFICATE_PRINCIPAL)); - - assertThat(startDateCaptor.getValue(), is(notNullValue())); - long diffMillis = startDateCaptor.getValue().getTime() - new Date().getTime(); - long days = TimeUnit.MILLISECONDS.toDays(diffMillis); - assertThat(days, is(0L)); //Date is Today - - assertThat(endDateCaptor.getValue(), is(notNullValue())); - diffMillis = endDateCaptor.getValue().getTime() - new Date().getTime(); - days = TimeUnit.MILLISECONDS.toDays(diffMillis); - assertThat(days, is(greaterThan(25 * 365L))); //Date more than 25 Years in days - - assertThat(entry, is(expectedEntry)); - } - - @Test - @Config(sdk = 23) - public void shouldCreateRSAKeyPairIfMissingOnAPI23AndUp() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 23); - - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - KeyStore.PrivateKeyEntry expectedEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); - - KeyGenParameterSpec spec = PowerMockito.mock(KeyGenParameterSpec.class); - KeyGenParameterSpec.Builder builder = newKeyGenParameterSpecBuilder(spec); - PowerMockito.whenNew(KeyGenParameterSpec.Builder.class).withArguments(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT).thenReturn(builder); + // Verify the spec properties directly + KeyGenParameterSpec spec = (KeyGenParameterSpec) specCaptor.getValue(); + assertThat(spec.getKeySize(), is(2048)); + assertThat(spec.getKeystoreAlias(), is(KEY_ALIAS)); + assertThat(spec.getCertificateSerialNumber(), is(BigInteger.ONE)); + assertThat(spec.getEncryptionPaddings(), is(new String[]{KeyProperties.ENCRYPTION_PADDING_RSA_OAEP})); + assertThat(spec.getDigests(), is(new String[]{KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256})); + assertThat(spec.getBlockModes(), is(new String[]{KeyProperties.BLOCK_MODE_ECB})); - ArgumentCaptor principalCaptor = ArgumentCaptor.forClass(X500Principal.class); - ArgumentCaptor startDateCaptor = ArgumentCaptor.forClass(Date.class); - ArgumentCaptor endDateCaptor = ArgumentCaptor.forClass(Date.class); - - - final KeyStore.PrivateKeyEntry entry = cryptoUtil.getRSAKeyEntry(); + assertThat(spec.getCertificateSubject(), is(notNullValue())); + assertThat(spec.getCertificateSubject().getName(), is(CERTIFICATE_PRINCIPAL)); - - Mockito.verify(builder).setKeySize(2048); - Mockito.verify(builder).setCertificateSubject(principalCaptor.capture()); - Mockito.verify(builder).setCertificateSerialNumber(BigInteger.ONE); - Mockito.verify(builder).setCertificateNotBefore(startDateCaptor.capture()); - Mockito.verify(builder).setCertificateNotAfter(endDateCaptor.capture()); - Mockito.verify(builder).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP); - Mockito.verify(builder).setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256); - Mockito.verify(builder).setBlockModes(KeyProperties.BLOCK_MODE_ECB); - Mockito.verify(keyPairGenerator).initialize(spec); - Mockito.verify(keyPairGenerator).generateKeyPair(); - - assertThat(principalCaptor.getValue(), is(notNullValue())); - assertThat(principalCaptor.getValue().getName(), is(CERTIFICATE_PRINCIPAL)); - - assertThat(startDateCaptor.getValue(), is(notNullValue())); - long diffMillis = startDateCaptor.getValue().getTime() - new Date().getTime(); + assertThat(spec.getCertificateNotBefore(), is(notNullValue())); + long diffMillis = spec.getCertificateNotBefore().getTime() - new Date().getTime(); long days = TimeUnit.MILLISECONDS.toDays(diffMillis); assertThat(days, is(0L)); //Date is Today - assertThat(endDateCaptor.getValue(), is(notNullValue())); - diffMillis = endDateCaptor.getValue().getTime() - new Date().getTime(); + assertThat(spec.getCertificateNotAfter(), is(notNullValue())); + diffMillis = spec.getCertificateNotAfter().getTime() - new Date().getTime(); days = TimeUnit.MILLISECONDS.toDays(diffMillis); assertThat(days, is(greaterThan(25 * 365L))); //Date more than 25 Years in days @@ -344,44 +210,37 @@ public void shouldCreateRSAKeyPairIfMissingOnAPI23AndUp() throws Exception { @Test @Config(sdk = 28) public void shouldCreateRSAKeyPairIfMissingOnAPI28AndUp() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 28); - - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - KeyStore.PrivateKeyEntry expectedEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); - KeyGenParameterSpec spec = PowerMockito.mock(KeyGenParameterSpec.class); - KeyGenParameterSpec.Builder builder = newKeyGenParameterSpecBuilder(spec); - PowerMockito.whenNew(KeyGenParameterSpec.Builder.class).withArguments(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT).thenReturn(builder); - - ArgumentCaptor principalCaptor = ArgumentCaptor.forClass(X500Principal.class); - ArgumentCaptor startDateCaptor = ArgumentCaptor.forClass(Date.class); - ArgumentCaptor endDateCaptor = ArgumentCaptor.forClass(Date.class); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); + KeyStore.PrivateKeyEntry expectedEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); + Mockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); + ArgumentCaptor specCaptor = ArgumentCaptor.forClass(AlgorithmParameterSpec.class); final KeyStore.PrivateKeyEntry entry = cryptoUtil.getRSAKeyEntry(); - Mockito.verify(builder).setKeySize(2048); - Mockito.verify(builder).setCertificateSubject(principalCaptor.capture()); - Mockito.verify(builder).setCertificateSerialNumber(BigInteger.ONE); - Mockito.verify(builder).setCertificateNotBefore(startDateCaptor.capture()); - Mockito.verify(builder).setCertificateNotAfter(endDateCaptor.capture()); - Mockito.verify(builder).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP); - Mockito.verify(builder).setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256); - Mockito.verify(builder).setBlockModes(KeyProperties.BLOCK_MODE_ECB); - Mockito.verify(keyPairGenerator).initialize(spec); + Mockito.verify(keyPairGenerator).initialize(specCaptor.capture()); Mockito.verify(keyPairGenerator).generateKeyPair(); - assertThat(principalCaptor.getValue(), is(notNullValue())); - assertThat(principalCaptor.getValue().getName(), is(CERTIFICATE_PRINCIPAL)); + // Verify the spec properties directly + KeyGenParameterSpec spec = (KeyGenParameterSpec) specCaptor.getValue(); + assertThat(spec.getKeySize(), is(2048)); + assertThat(spec.getKeystoreAlias(), is(KEY_ALIAS)); + assertThat(spec.getCertificateSerialNumber(), is(BigInteger.ONE)); + assertThat(spec.getEncryptionPaddings(), is(new String[]{KeyProperties.ENCRYPTION_PADDING_RSA_OAEP})); + assertThat(spec.getDigests(), is(new String[]{KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256})); + assertThat(spec.getBlockModes(), is(new String[]{KeyProperties.BLOCK_MODE_ECB})); + + assertThat(spec.getCertificateSubject(), is(notNullValue())); + assertThat(spec.getCertificateSubject().getName(), is(CERTIFICATE_PRINCIPAL)); - assertThat(startDateCaptor.getValue(), is(notNullValue())); - long diffMillis = startDateCaptor.getValue().getTime() - new Date().getTime(); + assertThat(spec.getCertificateNotBefore(), is(notNullValue())); + long diffMillis = spec.getCertificateNotBefore().getTime() - new Date().getTime(); long days = TimeUnit.MILLISECONDS.toDays(diffMillis); assertThat(days, is(0L)); //Date is Today - assertThat(endDateCaptor.getValue(), is(notNullValue())); - diffMillis = endDateCaptor.getValue().getTime() - new Date().getTime(); + assertThat(spec.getCertificateNotAfter(), is(notNullValue())); + diffMillis = spec.getCertificateNotAfter().getTime() - new Date().getTime(); days = TimeUnit.MILLISECONDS.toDays(diffMillis); assertThat(days, is(greaterThan(25 * 365L))); //Date more than 25 Years in days @@ -391,55 +250,42 @@ public void shouldCreateRSAKeyPairIfMissingOnAPI28AndUp() throws Exception { @Test @Config(sdk = 28) public void shouldCreateNewRSAKeyPairWhenExistingRSAKeyPairCannotBeRebuiltOnAPI28AndUp() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 28); - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - - //This is required to trigger the fallback when alias is present but key is not - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); - PowerMockito.when(keyStore.getKey(KEY_ALIAS, null)).thenReturn(privateKey).thenReturn(null); - PowerMockito.when(keyStore.getCertificate(KEY_ALIAS)).thenReturn(null); - //This is required to trigger finding the key after generating it - KeyStore.PrivateKeyEntry expectedEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); - //Tests no instantiation of PrivateKeyEntry - PowerMockito.verifyZeroInteractions(KeyStore.PrivateKeyEntry.class); - - //Creation assertion - KeyGenParameterSpec spec = PowerMockito.mock(KeyGenParameterSpec.class); - KeyGenParameterSpec.Builder builder = newKeyGenParameterSpecBuilder(spec); - PowerMockito.whenNew(KeyGenParameterSpec.Builder.class).withArguments(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT).thenReturn(builder); - - ArgumentCaptor principalCaptor = ArgumentCaptor.forClass(X500Principal.class); - ArgumentCaptor startDateCaptor = ArgumentCaptor.forClass(Date.class); - ArgumentCaptor endDateCaptor = ArgumentCaptor.forClass(Date.class); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); + Mockito.when(keyStore.getKey(KEY_ALIAS, null)).thenReturn(privateKey).thenReturn(null); + Mockito.when(keyStore.getCertificate(KEY_ALIAS)).thenReturn(null); + KeyStore.PrivateKeyEntry expectedEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); + Mockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(expectedEntry); + ArgumentCaptor specCaptor = ArgumentCaptor.forClass(AlgorithmParameterSpec.class); final KeyStore.PrivateKeyEntry entry = cryptoUtil.getRSAKeyEntry(); - Mockito.verify(builder).setKeySize(2048); - Mockito.verify(builder).setCertificateSubject(principalCaptor.capture()); - Mockito.verify(builder).setCertificateSerialNumber(BigInteger.ONE); - Mockito.verify(builder).setCertificateNotBefore(startDateCaptor.capture()); - Mockito.verify(builder).setCertificateNotAfter(endDateCaptor.capture()); - Mockito.verify(builder).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP); - Mockito.verify(builder).setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256); - Mockito.verify(builder).setBlockModes(KeyProperties.BLOCK_MODE_ECB); - Mockito.verify(keyPairGenerator).initialize(spec); + Mockito.verify(keyPairGenerator).initialize(specCaptor.capture()); Mockito.verify(keyPairGenerator).generateKeyPair(); - assertThat(principalCaptor.getValue(), is(notNullValue())); - assertThat(principalCaptor.getValue().getName(), is(CERTIFICATE_PRINCIPAL)); + // Verify the spec properties directly + KeyGenParameterSpec spec = (KeyGenParameterSpec) specCaptor.getValue(); + assertThat(spec.getKeySize(), is(2048)); + assertThat(spec.getKeystoreAlias(), is(KEY_ALIAS)); + assertThat(spec.getCertificateSerialNumber(), is(BigInteger.ONE)); + assertThat(spec.getEncryptionPaddings(), is(new String[]{KeyProperties.ENCRYPTION_PADDING_RSA_OAEP})); + assertThat(spec.getDigests(), is(new String[]{KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256})); + assertThat(spec.getBlockModes(), is(new String[]{KeyProperties.BLOCK_MODE_ECB})); + + assertThat(spec.getCertificateSubject(), is(notNullValue())); + assertThat(spec.getCertificateSubject().getName(), is(CERTIFICATE_PRINCIPAL)); - assertThat(startDateCaptor.getValue(), is(notNullValue())); - long diffMillis = startDateCaptor.getValue().getTime() - new Date().getTime(); + assertThat(spec.getCertificateNotBefore(), is(notNullValue())); + long diffMillis = spec.getCertificateNotBefore().getTime() - new Date().getTime(); long days = TimeUnit.MILLISECONDS.toDays(diffMillis); assertThat(days, is(0L)); //Date is Today - assertThat(endDateCaptor.getValue(), is(notNullValue())); - diffMillis = endDateCaptor.getValue().getTime() - new Date().getTime(); + assertThat(spec.getCertificateNotAfter(), is(notNullValue())); + diffMillis = spec.getCertificateNotAfter().getTime() - new Date().getTime(); days = TimeUnit.MILLISECONDS.toDays(diffMillis); - assertThat(days, is(greaterThan(25 * 365L))); //Date more than 25 Years in days + assertThat(days, is(greaterThan(25 * 365L))); assertThat(entry, is(expectedEntry)); } @@ -447,68 +293,57 @@ public void shouldCreateNewRSAKeyPairWhenExistingRSAKeyPairCannotBeRebuiltOnAPI2 @Test @Config(sdk = 28) public void shouldUseExistingRSAKeyPairRebuildingTheEntryOnAPI28AndUp() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 28); - KeyStore.PrivateKeyEntry entry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - Certificate certificate = PowerMockito.mock(Certificate.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + Certificate certificate = Mockito.mock(Certificate.class); - ArgumentCaptor varargsCaptor = ArgumentCaptor.forClass(Object.class); - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); - PowerMockito.when(keyStore.getKey(KEY_ALIAS, null)).thenReturn(privateKey); - PowerMockito.when(keyStore.getCertificate(KEY_ALIAS)).thenReturn(certificate); - PowerMockito.whenNew(KeyStore.PrivateKeyEntry.class).withAnyArguments().thenReturn(entry); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); + Mockito.when(keyStore.getKey(KEY_ALIAS, null)).thenReturn(privateKey); + Mockito.when(keyStore.getCertificate(KEY_ALIAS)).thenReturn(certificate); - KeyStore.PrivateKeyEntry rsaEntry = cryptoUtil.getRSAKeyEntry(); - PowerMockito.verifyNew(KeyStore.PrivateKeyEntry.class).withArguments(varargsCaptor.capture()); - assertThat(rsaEntry, is(notNullValue())); - assertThat(rsaEntry, is(entry)); - assertThat(varargsCaptor.getAllValues(), is(notNullValue())); - PrivateKey capturedPrivateKey = (PrivateKey) varargsCaptor.getAllValues().get(0); - Certificate[] capturedCertificatesArray = (Certificate[]) varargsCaptor.getAllValues().get(1); - assertThat(capturedPrivateKey, is(privateKey)); - assertThat(capturedCertificatesArray[0], is(certificate)); - assertThat(capturedCertificatesArray.length, is(1)); + // Use mockConstruction to intercept PrivateKeyEntry constructor + try (MockedConstruction mockedConstruction = Mockito.mockConstruction( + KeyStore.PrivateKeyEntry.class, + (mock, context) -> { + // Capture constructor arguments + })) { + + KeyStore.PrivateKeyEntry rsaEntry = cryptoUtil.getRSAKeyEntry(); + + assertThat(rsaEntry, is(notNullValue())); + assertThat(mockedConstruction.constructed().size(), is(1)); + assertThat(rsaEntry, is(mockedConstruction.constructed().get(0))); + } } @Test @Config(sdk = 28) public void shouldUseExistingPrivateKeyForOldKeyAlias() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 28); - KeyStore.PrivateKeyEntry entry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - Certificate certificate = PowerMockito.mock(Certificate.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + Certificate certificate = Mockito.mock(Certificate.class); - KeyGenParameterSpec.Builder builder = PowerMockito.mock(KeyGenParameterSpec.Builder.class); - PowerMockito.when(builder.setKeySize(anyInt())).thenReturn(builder); - PowerMockito.when(builder.setCertificateSubject(any(X500Principal.class))).thenReturn(builder); + Mockito.when(keyStore.containsAlias(OLD_KEY_ALIAS)).thenReturn(true); + Mockito.when(keyStore.getKey(OLD_KEY_ALIAS, null)).thenReturn(privateKey); + Mockito.when(keyStore.getCertificate(OLD_KEY_ALIAS)).thenReturn(certificate); - ArgumentCaptor varargsCaptor = ArgumentCaptor.forClass(Object.class); - PowerMockito.when(keyStore.containsAlias(OLD_KEY_ALIAS)).thenReturn(true); - PowerMockito.when(keyStore.getKey(OLD_KEY_ALIAS, null)).thenReturn(privateKey); - PowerMockito.when(keyStore.getCertificate(OLD_KEY_ALIAS)).thenReturn(certificate); - PowerMockito.whenNew(KeyStore.PrivateKeyEntry.class).withAnyArguments().thenReturn(entry); + try (MockedConstruction mockedConstruction = Mockito.mockConstruction( + KeyStore.PrivateKeyEntry.class, + (mock, context) -> { + })) { - KeyStore.PrivateKeyEntry rsaEntry = cryptoUtil.getRSAKeyEntry(); - PowerMockito.verifyNew(KeyStore.PrivateKeyEntry.class).withArguments(varargsCaptor.capture()); - assertThat(rsaEntry, is(notNullValue())); - assertThat(rsaEntry, is(entry)); - assertThat(varargsCaptor.getAllValues(), is(notNullValue())); - PrivateKey capturedPrivateKey = (PrivateKey) varargsCaptor.getAllValues().get(0); - Certificate[] capturedCertificatesArray = (Certificate[]) varargsCaptor.getAllValues().get(1); - assertThat(capturedPrivateKey, is(privateKey)); - assertThat(capturedCertificatesArray[0], is(certificate)); - assertThat(capturedCertificatesArray.length, is(1)); + KeyStore.PrivateKeyEntry rsaEntry = cryptoUtil.getRSAKeyEntry(); + + assertThat(rsaEntry, is(notNullValue())); + assertThat(mockedConstruction.constructed().size(), is(1)); + assertThat(rsaEntry, is(mockedConstruction.constructed().get(0))); + } } @Test @Config(sdk = 28) public void shouldUseExistingRSAKeyPairOnAPI28AndUp() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 28); - KeyStore.PrivateKeyEntry entry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(entry); - PrivateKey privateKey = null; - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); - PowerMockito.when(keyStore.getKey(KEY_ALIAS, null)).thenReturn(privateKey); + KeyStore.PrivateKeyEntry entry = Mockito.mock(KeyStore.PrivateKeyEntry.class); + Mockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(entry); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); KeyStore.PrivateKeyEntry rsaEntry = cryptoUtil.getRSAKeyEntry(); assertThat(rsaEntry, is(notNullValue())); @@ -518,10 +353,9 @@ public void shouldUseExistingRSAKeyPairOnAPI28AndUp() throws Exception { @Test @Config(sdk = 27) public void shouldUseExistingRSAKeyPairOnAPI27AndDown() throws Exception { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 27); - KeyStore.PrivateKeyEntry entry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(entry); + KeyStore.PrivateKeyEntry entry = Mockito.mock(KeyStore.PrivateKeyEntry.class); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); + Mockito.when(keyStore.getEntry(KEY_ALIAS, null)).thenReturn(entry); KeyStore.PrivateKeyEntry rsaEntry = cryptoUtil.getRSAKeyEntry(); assertThat(rsaEntry, is(notNullValue())); @@ -532,9 +366,9 @@ public void shouldUseExistingRSAKeyPairOnAPI27AndDown() throws Exception { public void shouldDeleteRSAAndAESKeysAndThrowOnUnrecoverableEntryExceptionWhenTryingToObtainRSAKeys() throws Exception { Assert.assertThrows("The existing RSA key pair could not be recovered and has been deleted. " + "This occasionally happens when the Lock Screen settings are changed. You can safely retry this operation.", CryptoException.class, () -> { - KeyStore.PrivateKeyEntry entry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); - PowerMockito.when(keyStore.getEntry(KEY_ALIAS, null)) + KeyStore.PrivateKeyEntry entry = Mockito.mock(KeyStore.PrivateKeyEntry.class); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); + Mockito.when(keyStore.getEntry(KEY_ALIAS, null)) .thenThrow(new UnrecoverableEntryException()) .thenReturn(entry); @@ -591,8 +425,7 @@ public void shouldDeleteAESKeysAndThrowOnDoubleIOExceptionWhenTryingToObtainRSAK @Test public void shouldThrowOnKeyStoreExceptionWhenTryingToObtainRSAKeys() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PowerMockito.mockStatic(KeyStore.class); - PowerMockito.when(KeyStore.getInstance(anyString())) + Mockito.when(KeyStore.getInstance(anyString())) .thenThrow(new KeyStoreException()); cryptoUtil.getRSAKeyEntry(); @@ -619,15 +452,10 @@ public void shouldThrowOnProviderExceptionWhenTryingToObtainRSAKeys() { @Test public void shouldThrowOnNoSuchProviderExceptionWhenTryingToObtainRSAKeys() { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 19); Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - KeyPairGeneratorSpec spec = PowerMockito.mock(KeyPairGeneratorSpec.class); - KeyPairGeneratorSpec.Builder builder = newKeyPairGeneratorSpecBuilder(spec); - PowerMockito.whenNew(KeyPairGeneratorSpec.Builder.class).withAnyArguments().thenReturn(builder); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - PowerMockito.mockStatic(KeyPairGenerator.class); - PowerMockito.when(KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE)) + keyPairGeneratorMock.when(() -> KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE)) .thenThrow(new NoSuchProviderException()); cryptoUtil.getRSAKeyEntry(); @@ -636,15 +464,10 @@ public void shouldThrowOnNoSuchProviderExceptionWhenTryingToObtainRSAKeys() { @Test public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToObtainRSAKeys() { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 19); Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - KeyPairGeneratorSpec spec = PowerMockito.mock(KeyPairGeneratorSpec.class); - KeyPairGeneratorSpec.Builder builder = newKeyPairGeneratorSpecBuilder(spec); - PowerMockito.whenNew(KeyPairGeneratorSpec.Builder.class).withAnyArguments().thenReturn(builder); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - PowerMockito.mockStatic(KeyPairGenerator.class); - PowerMockito.when(KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE)) + keyPairGeneratorMock.when(() -> KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE)) .thenThrow(new NoSuchAlgorithmException()); cryptoUtil.getRSAKeyEntry(); @@ -653,12 +476,8 @@ public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToObtainRSAKeys() { @Test public void shouldThrowOnInvalidAlgorithmParameterExceptionWhenTryingToObtainRSAKeys() { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", 19); Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PowerMockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); - KeyPairGeneratorSpec spec = PowerMockito.mock(KeyPairGeneratorSpec.class); - KeyPairGeneratorSpec.Builder builder = newKeyPairGeneratorSpecBuilder(spec); - PowerMockito.whenNew(KeyPairGeneratorSpec.Builder.class).withAnyArguments().thenReturn(builder); + Mockito.when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); doThrow(new InvalidAlgorithmParameterException()).when(keyPairGenerator).initialize(any(AlgorithmParameterSpec.class)); @@ -673,17 +492,15 @@ public void shouldThrowOnInvalidAlgorithmParameterExceptionWhenTryingToObtainRSA @Test public void shouldCreateAESKeyIfMissing() throws Exception { byte[] sampleBytes = new byte[]{0, 1, 2, 3, 4, 5}; - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.encode(sampleBytes, Base64.DEFAULT)).thenReturn("data".getBytes()); - PowerMockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); - PowerMockito.when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); - PowerMockito.mockStatic(TextUtils.class); - PowerMockito.when(TextUtils.isEmpty(null)).thenReturn(true); - - SecretKey secretKey = PowerMockito.mock(SecretKey.class); - PowerMockito.when(keyGenerator.generateKey()).thenReturn(secretKey); - PowerMockito.when(secretKey.getEncoded()).thenReturn(sampleBytes); - PowerMockito.doReturn(sampleBytes).when(cryptoUtil, "RSAEncrypt", sampleBytes); + base64Mock.when(() -> Base64.encode(sampleBytes, Base64.DEFAULT)).thenReturn("data".getBytes()); + Mockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); + Mockito.when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + textUtilsMock.when(() -> TextUtils.isEmpty(null)).thenReturn(true); + + SecretKey secretKey = Mockito.mock(SecretKey.class); + Mockito.when(keyGenerator.generateKey()).thenReturn(secretKey); + Mockito.when(secretKey.getEncoded()).thenReturn(sampleBytes); + Mockito.doReturn(sampleBytes).when(cryptoUtil).RSAEncrypt(sampleBytes); final byte[] aesKey = cryptoUtil.getAESKey(); @@ -700,22 +517,21 @@ public void shouldCreateAESKeyIfStoredOneIsEmpty() throws BadPaddingException, I String emptyString = ""; byte[] sampleBytes = emptyString.getBytes(); byte[] sampleOutput = new byte[]{99, 33, 11}; - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode(emptyString, Base64.DEFAULT)).thenReturn(sampleBytes); - PowerMockito.when(Base64.encode(sampleBytes, Base64.DEFAULT)).thenReturn("data".getBytes()); - PowerMockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn(emptyString); + base64Mock.when(() -> Base64.decode(emptyString, Base64.DEFAULT)).thenReturn(sampleBytes); + base64Mock.when(() -> Base64.encode(sampleBytes, Base64.DEFAULT)).thenReturn("data".getBytes()); + Mockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn(emptyString); doReturn(sampleBytes).when(cryptoUtil).RSAEncrypt(sampleBytes); //Assume RSAKeyEntry exists - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); doReturn(sampleOutput).when(rsaOaepCipher).doFinal(sampleBytes); - SecretKey secretKey = PowerMockito.mock(SecretKey.class); - PowerMockito.when(secretKey.getEncoded()).thenReturn(sampleBytes); - PowerMockito.when(keyGenerator.generateKey()).thenReturn(secretKey); + SecretKey secretKey = Mockito.mock(SecretKey.class); + Mockito.when(secretKey.getEncoded()).thenReturn(sampleBytes); + Mockito.when(keyGenerator.generateKey()).thenReturn(secretKey); final byte[] aesKey = cryptoUtil.getAESKey(); @@ -735,9 +551,8 @@ public void shouldUseExistingAESKey() { Arrays.fill(sampleBytes, (byte) 1); String aesString = "non null string"; - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode(aesString, Base64.DEFAULT)).thenReturn(sampleBytes); - PowerMockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn(aesString); + base64Mock.when(() -> Base64.decode(aesString, Base64.DEFAULT)).thenReturn(sampleBytes); + Mockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn(aesString); doReturn(sampleBytes).when(cryptoUtil).RSADecrypt(sampleBytes); final byte[] aesKey = cryptoUtil.getAESKey(); @@ -748,12 +563,10 @@ public void shouldUseExistingAESKey() { @Test public void shouldThrowOnNoSuchAlgorithmExceptionWhenCreatingAESKey() throws Exception { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PowerMockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); - PowerMockito.when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); - PowerMockito.mockStatic(TextUtils.class); - PowerMockito.when(TextUtils.isEmpty(null)).thenReturn(true); - PowerMockito.mockStatic(KeyGenerator.class); - PowerMockito.when(KeyGenerator.getInstance(ALGORITHM_AES)) + Mockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); + Mockito.when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + textUtilsMock.when(() -> TextUtils.isEmpty(null)).thenReturn(true); + Mockito.when(KeyGenerator.getInstance(ALGORITHM_AES)) .thenThrow(new NoSuchAlgorithmException()); cryptoUtil.getAESKey(); @@ -769,10 +582,10 @@ public void shouldRSAEncryptData() throws Exception { byte[] sampleInput = new byte[]{0, 1, 2, 3, 4, 5}; byte[] sampleOutput = new byte[]{99, 33, 11}; - PublicKey publicKey = PowerMockito.mock(PublicKey.class); - Certificate certificate = PowerMockito.mock(Certificate.class); + PublicKey publicKey = Mockito.mock(PublicKey.class); + Certificate certificate = Mockito.mock(Certificate.class); doReturn(publicKey).when(certificate).getPublicKey(); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(certificate).when(privateKeyEntry).getCertificate(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); doReturn(sampleOutput).when(rsaOaepCipher).doFinal(sampleInput); @@ -787,14 +600,13 @@ public void shouldRSAEncryptData() throws Exception { public void shouldThrowOnInvalidKeyExceptionWhenTryingToRSAEncrypt() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { byte[] sampleBytes = new byte[0]; - PublicKey publicKey = PowerMockito.mock(PublicKey.class); - Certificate certificate = PowerMockito.mock(Certificate.class); + PublicKey publicKey = Mockito.mock(PublicKey.class); + Certificate certificate = Mockito.mock(Certificate.class); doReturn(publicKey).when(certificate).getPublicKey(); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(certificate).when(privateKeyEntry).getCertificate(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); doThrow(new InvalidKeyException()).when(rsaOaepCipher).init(eq(Cipher.ENCRYPT_MODE), eq(publicKey), any(AlgorithmParameterSpec.class)); cryptoUtil.RSAEncrypt(sampleBytes); @@ -806,13 +618,12 @@ public void shouldDeleteAESKeysAndThrowOnBadPaddingExceptionWhenTryingToRSAEncry Assert.assertThrows("The RSA decrypted input is invalid.", CryptoException.class, () -> { byte[] sampleBytes = new byte[0]; - Certificate certificate = PowerMockito.mock(Certificate.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + Certificate certificate = Mockito.mock(Certificate.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(certificate).when(privateKeyEntry).getCertificate(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); - PowerMockito.when(rsaOaepCipher.doFinal(sampleBytes)).thenThrow(new BadPaddingException()); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); + Mockito.when(rsaOaepCipher.doFinal(sampleBytes)).thenThrow(new BadPaddingException()); cryptoUtil.RSAEncrypt(sampleBytes); }); @@ -828,13 +639,12 @@ public void shouldDeleteAESKeysAndThrowOnBadPaddingExceptionWhenTryingToRSAEncry @Test public void shouldDeleteAESKeysAndThrowOnIllegalBlockSizeExceptionWhenTryingToRSAEncrypt() throws Exception { Assert.assertThrows("The RSA decrypted input is invalid.", CryptoException.class, () -> { - Certificate certificate = PowerMockito.mock(Certificate.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + Certificate certificate = Mockito.mock(Certificate.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(certificate).when(privateKeyEntry).getCertificate(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); - PowerMockito.when(rsaOaepCipher.doFinal(any(byte[].class))).thenThrow(new IllegalBlockSizeException()); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); + Mockito.when(rsaOaepCipher.doFinal(any(byte[].class))).thenThrow(new IllegalBlockSizeException()); cryptoUtil.RSAEncrypt(new byte[0]); }); @@ -850,12 +660,11 @@ public void shouldDeleteAESKeysAndThrowOnIllegalBlockSizeExceptionWhenTryingToRS @Test public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToRSAEncrypt() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - Certificate certificate = PowerMockito.mock(Certificate.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + Certificate certificate = Mockito.mock(Certificate.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(certificate).when(privateKeyEntry).getCertificate(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenThrow(new NoSuchAlgorithmException()); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenThrow(new NoSuchAlgorithmException()); cryptoUtil.RSAEncrypt(new byte[0]); }); @@ -864,12 +673,11 @@ public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToRSAEncrypt() { @Test public void shouldThrowOnNoSuchPaddingExceptionWhenTryingToRSAEncrypt() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - Certificate certificate = PowerMockito.mock(Certificate.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + Certificate certificate = Mockito.mock(Certificate.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(certificate).when(privateKeyEntry).getCertificate(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenThrow(new NoSuchPaddingException()); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenThrow(new NoSuchPaddingException()); cryptoUtil.RSAEncrypt(new byte[0]); }); @@ -884,8 +692,8 @@ public void shouldRSADecryptData() throws Exception { byte[] sampleInput = new byte[]{0, 1, 2, 3, 4, 5}; byte[] sampleOutput = new byte[]{99, 33, 11}; - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); doReturn(sampleOutput).when(rsaOaepCipher).doFinal(sampleInput); @@ -900,12 +708,11 @@ public void shouldRSADecryptData() throws Exception { public void shouldThrowOnInvalidKeyExceptionWhenTryingToRSADecrypt() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { byte[] sampleBytes = new byte[0]; - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); doThrow(new InvalidKeyException()).when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), any(AlgorithmParameterSpec.class)); cryptoUtil.RSADecrypt(sampleBytes); @@ -915,12 +722,11 @@ public void shouldThrowOnInvalidKeyExceptionWhenTryingToRSADecrypt() { @Test public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToRSADecrypt() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenThrow(new NoSuchAlgorithmException()); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenThrow(new NoSuchAlgorithmException()); cryptoUtil.RSADecrypt(new byte[0]); }); @@ -929,12 +735,11 @@ public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToRSADecrypt() { @Test public void shouldThrowOnNoSuchPaddingExceptionWhenTryingToRSADecrypt() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenThrow(new NoSuchPaddingException()); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenThrow(new NoSuchPaddingException()); cryptoUtil.RSADecrypt(new byte[0]); }); @@ -943,8 +748,8 @@ public void shouldThrowOnNoSuchPaddingExceptionWhenTryingToRSADecrypt() { @Test public void shouldDeleteAESKeysAndThrowOnBadPaddingExceptionWhenTryingToRSADecrypt() throws Exception { Assert.assertThrows("The RSA encrypted input is corrupted and cannot be recovered. Please discard it.", CryptoException.class, () -> { - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); @@ -963,8 +768,8 @@ public void shouldDeleteAESKeysAndThrowOnBadPaddingExceptionWhenTryingToRSADecry @Test public void shouldDeleteAESKeysAndThrowOnIllegalBlockSizeExceptionWhenTryingToRSADecrypt() throws Exception { Assert.assertThrows("The RSA encrypted input is corrupted and cannot be recovered. Please discard it.", CryptoException.class, () -> { - PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); - KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); @@ -994,8 +799,8 @@ public void shouldAESEncryptData() throws Exception { doReturn(aesKey).when(cryptoUtil).getAESKey(); doReturn(encryptedData).when(aesCipher).doFinal(data); - PowerMockito.when(aesCipher.doFinal(data)).thenReturn(encryptedData); - PowerMockito.when(aesCipher.getIV()).thenReturn(iv); + Mockito.when(aesCipher.doFinal(data)).thenReturn(encryptedData); + Mockito.when(aesCipher.getIV()).thenReturn(iv); final byte[] encrypted = cryptoUtil.encrypt(data); @@ -1006,17 +811,17 @@ public void shouldAESEncryptData() throws Exception { // IV is NO LONGER stored in storage - it's bundled with the encrypted data Mockito.verify(storage, never()).store(eq(KEY_ALIAS + "_iv"), anyString()); - + assertThat(encrypted, is(notNullValue())); assertThat(encrypted.length, is(1 + 1 + iv.length + encryptedData.length)); assertThat(encrypted[0], is((byte) 0x01)); assertThat(encrypted[1], is((byte) iv.length)); - + // Verify IV is correctly embedded byte[] extractedIV = new byte[iv.length]; System.arraycopy(encrypted, 2, extractedIV, 0, iv.length); assertThat(extractedIV, is(iv)); - + // Verify encrypted data is correctly embedded byte[] extractedEncrypted = new byte[encryptedData.length]; System.arraycopy(encrypted, 2 + iv.length, extractedEncrypted, 0, encryptedData.length); @@ -1026,9 +831,8 @@ public void shouldAESEncryptData() throws Exception { @Test public void shouldThrowOnCryptoExceptionOnRSAKeyReadingWhenTryingToAESEncrypt() { Assert.assertThrows(CryptoException.class, () -> { - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("encoded-key", Base64.DEFAULT)).thenReturn(new byte[0]); - PowerMockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn("encoded-key"); + base64Mock.when(() -> Base64.decode("encoded-key", Base64.DEFAULT)).thenReturn(new byte[0]); + Mockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn("encoded-key"); doThrow(new CryptoException("err", null)).when(cryptoUtil).getRSAKeyEntry(); cryptoUtil.encrypt(new byte[0]); @@ -1046,9 +850,8 @@ public void shouldThrowOnCryptoExceptionOnAESKeyReadingWhenTryingToAESEncrypt() @Test public void shouldThrowOnIncompatibleDeviceExceptionOnRSAKeyReadingWhenTryingToAESEncrypt() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("encoded-key", Base64.DEFAULT)).thenReturn(new byte[0]); - PowerMockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn("encoded-key"); + base64Mock.when(() -> Base64.decode("encoded-key", Base64.DEFAULT)).thenReturn(new byte[0]); + Mockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn("encoded-key"); doThrow(new IncompatibleDeviceException(null)).when(cryptoUtil).getRSAKeyEntry(); cryptoUtil.encrypt(new byte[0]); @@ -1069,8 +872,7 @@ public void shouldThrowOnNoSuchPaddingExceptionWhenTryingToAESEncrypt() { Assert.assertThrows(IncompatibleDeviceException.class, () -> { doReturn(new byte[]{11, 22, 33}).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenThrow(new NoSuchPaddingException()); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenThrow(new NoSuchPaddingException()); cryptoUtil.encrypt(new byte[0]); }); @@ -1081,8 +883,7 @@ public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToAESEncrypt() throws Assert.assertThrows(IncompatibleDeviceException.class, () -> { doReturn(new byte[]{11, 22, 33}).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenThrow(new NoSuchAlgorithmException()); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenThrow(new NoSuchAlgorithmException()); cryptoUtil.encrypt(new byte[0]); }); @@ -1096,8 +897,7 @@ public void shouldThrowOnInvalidKeyExceptionWhenTryingToAESEncrypt() throws Exce try { doReturn(aesKeyBytes).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); doThrow(new InvalidKeyException()).when(aesCipher).init(eq(Cipher.ENCRYPT_MODE), secretKeyArgumentCaptor.capture()); cryptoUtil.encrypt(new byte[0]); @@ -1160,9 +960,9 @@ public void shouldDetectNewFormatWithValidMarkerAndIVLength12() { for (int i = 2; i < newFormatData.length; i++) { newFormatData[i] = (byte) i; } - + boolean result = cryptoUtil.isNewFormat(newFormatData); - + assertThat(result, is(true)); } @@ -1177,9 +977,9 @@ public void shouldDetectNewFormatWithValidMarkerAndIVLength16() { for (int i = 2; i < newFormatData.length; i++) { newFormatData[i] = (byte) i; } - + boolean result = cryptoUtil.isNewFormat(newFormatData); - + assertThat(result, is(true)); } @@ -1189,9 +989,9 @@ public void shouldNotDetectNewFormatWithInvalidMarker() { byte[] invalidData = new byte[30]; invalidData[0] = 0x02; // Wrong marker invalidData[1] = 12; // Valid IV length - + boolean result = cryptoUtil.isNewFormat(invalidData); - + assertThat(result, is(false)); } @@ -1201,9 +1001,9 @@ public void shouldNotDetectNewFormatWithInvalidIVLength() { byte[] invalidData = new byte[30]; invalidData[0] = 0x01; // Valid marker invalidData[1] = 10; // Invalid IV length (not 12 or 16) - + boolean result = cryptoUtil.isNewFormat(invalidData); - + assertThat(result, is(false)); } @@ -1211,24 +1011,24 @@ public void shouldNotDetectNewFormatWithInvalidIVLength() { public void shouldExtractIVFromNewFormatCorrectly() { byte[] iv = new byte[]{10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120}; byte[] encryptedPayload = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17}; // At least 17 bytes (16 tag + 1 data) - + byte[] newFormatData = new byte[1 + 1 + iv.length + encryptedPayload.length]; newFormatData[0] = 0x01; newFormatData[1] = (byte) iv.length; System.arraycopy(iv, 0, newFormatData, 2, iv.length); System.arraycopy(encryptedPayload, 0, newFormatData, 2 + iv.length, encryptedPayload.length); - + // Verify format detection assertThat(cryptoUtil.isNewFormat(newFormatData), is(true)); - + // Manually extract and verify IV int ivLength = newFormatData[1] & 0xFF; assertThat(ivLength, is(12)); - + byte[] extractedIV = new byte[ivLength]; System.arraycopy(newFormatData, 2, extractedIV, 0, ivLength); assertThat(extractedIV, is(iv)); - + // Verify encrypted payload position int dataOffset = 2 + ivLength; int dataLength = newFormatData.length - dataOffset; @@ -1288,7 +1088,7 @@ public void shouldRejectInvalidIVLengthsInNewFormat() { byte[] ivLength255 = new byte[274]; ivLength255[0] = 0x01; - ivLength255[1] = (byte) 255; + ivLength255[1] = (byte) 255; assertThat(cryptoUtil.isNewFormat(ivLength255), is(false)); } @@ -1305,12 +1105,10 @@ public void shouldDecryptLegacyFormatDataWithIVInStorage() throws Exception { // Setup: Old format has IV stored separately in storage doReturn(aesKey).when(cryptoUtil).getAESKey(); - PowerMockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("encoded-iv-data"); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("encoded-iv-data", Base64.DEFAULT)).thenReturn(iv); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.when(aesCipher.doFinal(encryptedData)).thenReturn(originalData); + Mockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("encoded-iv-data"); + base64Mock.when(() -> Base64.decode("encoded-iv-data", Base64.DEFAULT)).thenReturn(iv); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); + Mockito.when(aesCipher.doFinal(encryptedData)).thenReturn(originalData); // Execute: Decrypt old format data (should be detected as legacy format) final byte[] decrypted = cryptoUtil.decrypt(encryptedData); @@ -1337,22 +1135,20 @@ public void shouldMigrateFromLegacyFormatToNewFormat() throws Exception { byte[] newIv = new byte[]{11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22}; doReturn(aesKey).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.mockStatic(Base64.class); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); // Step 1: Decrypt old format (IV from storage) - PowerMockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("old-encoded-iv"); - PowerMockito.when(Base64.decode("old-encoded-iv", Base64.DEFAULT)).thenReturn(oldIv); - PowerMockito.when(aesCipher.doFinal(oldEncryptedData)).thenReturn(originalData); + Mockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("old-encoded-iv"); + base64Mock.when(() -> Base64.decode("old-encoded-iv", Base64.DEFAULT)).thenReturn(oldIv); + Mockito.when(aesCipher.doFinal(oldEncryptedData)).thenReturn(originalData); byte[] decryptedOld = cryptoUtil.decrypt(oldEncryptedData); assertThat(decryptedOld, is(originalData)); assertThat(cryptoUtil.isNewFormat(oldEncryptedData), is(false)); // Step 2: Re-encrypt in new format (IV bundled) - PowerMockito.when(aesCipher.doFinal(originalData)).thenReturn(newEncryptedData); - PowerMockito.when(aesCipher.getIV()).thenReturn(newIv); + Mockito.when(aesCipher.doFinal(originalData)).thenReturn(newEncryptedData); + Mockito.when(aesCipher.getIV()).thenReturn(newIv); byte[] reEncrypted = cryptoUtil.encrypt(originalData); @@ -1367,7 +1163,7 @@ public void shouldMigrateFromLegacyFormatToNewFormat() throws Exception { assertThat(extractedIV, is(newIv)); // Step 3: Decrypt new format (IV bundled in data) - PowerMockito.when(aesCipher.doFinal(any(byte[].class), anyInt(), anyInt())).thenReturn(originalData); + Mockito.when(aesCipher.doFinal(any(byte[].class), anyInt(), anyInt())).thenReturn(originalData); byte[] decryptedNew = cryptoUtil.decrypt(reEncrypted); assertThat(decryptedNew, is(originalData)); @@ -1399,20 +1195,18 @@ public void shouldDecryptBothLegacyAndNewFormatInSameSession() throws Exception System.arraycopy(newEncryptedPayload, 0, newEncrypted, 2 + newIv.length, newEncryptedPayload.length); doReturn(aesKey).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.mockStatic(Base64.class); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); // Decrypt old format first - PowerMockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("old-iv-encoded"); - PowerMockito.when(Base64.decode("old-iv-encoded", Base64.DEFAULT)).thenReturn(oldIv); - PowerMockito.when(aesCipher.doFinal(oldEncrypted)).thenReturn(dataA); + Mockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("old-iv-encoded"); + base64Mock.when(() -> Base64.decode("old-iv-encoded", Base64.DEFAULT)).thenReturn(oldIv); + Mockito.when(aesCipher.doFinal(oldEncrypted)).thenReturn(dataA); byte[] decryptedOld = cryptoUtil.decrypt(oldEncrypted); assertThat(decryptedOld, is(dataA)); // Decrypt new format next - PowerMockito.when(aesCipher.doFinal(any(byte[].class), anyInt(), anyInt())).thenReturn(dataB); + Mockito.when(aesCipher.doFinal(any(byte[].class), anyInt(), anyInt())).thenReturn(dataB); byte[] decryptedNew = cryptoUtil.decrypt(newEncrypted); assertThat(decryptedNew, is(dataB)); @@ -1446,14 +1240,13 @@ public void shouldAESDecryptData() throws Exception { System.arraycopy(encryptedPayload, 0, newFormatData, 2 + iv.length, encryptedPayload.length); doReturn(aesKey).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.when(aesCipher.doFinal(any(byte[].class), anyInt(), anyInt())).thenReturn(originalData); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); + Mockito.when(aesCipher.doFinal(any(byte[].class), anyInt(), anyInt())).thenReturn(originalData); final byte[] decrypted = cryptoUtil.decrypt(newFormatData); assertThat(cryptoUtil.isNewFormat(newFormatData), is(true)); - + Mockito.verify(aesCipher).init(eq(Cipher.DECRYPT_MODE), secretKeyCaptor.capture(), ivParameterSpecCaptor.capture()); assertThat(secretKeyCaptor.getValue(), is(notNullValue())); assertThat(secretKeyCaptor.getValue().getAlgorithm(), is(ALGORITHM_AES)); @@ -1466,9 +1259,8 @@ public void shouldAESDecryptData() throws Exception { @Test public void shouldThrowOnCryptoExceptionOnRSAKeyReadingWhenTryingToAESDecrypt() { Assert.assertThrows(CryptoException.class, () -> { - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("encoded-key", Base64.DEFAULT)).thenReturn(new byte[0]); - PowerMockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn("encoded-key"); + base64Mock.when(() -> Base64.decode("encoded-key", Base64.DEFAULT)).thenReturn(new byte[0]); + Mockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn("encoded-key"); doThrow(new CryptoException("err", null)).when(cryptoUtil).getRSAKeyEntry(); cryptoUtil.decrypt(new byte[0]); @@ -1486,9 +1278,8 @@ public void shouldThrowOnCryptoExceptionOnAESKeyReadingWhenTryingToAESDecrypt() @Test public void shouldThrowOnIncompatibleDeviceExceptionOnRSAKeyReadingWhenTryingToAESDecrypt() { Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("encoded-key", Base64.DEFAULT)).thenReturn(new byte[0]); - PowerMockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn("encoded-key"); + base64Mock.when(() -> Base64.decode("encoded-key", Base64.DEFAULT)).thenReturn(new byte[0]); + Mockito.when(storage.retrieveString(KEY_ALIAS)).thenReturn("encoded-key"); doThrow(new IncompatibleDeviceException(null)).when(cryptoUtil).getRSAKeyEntry(); cryptoUtil.decrypt(new byte[0]); @@ -1508,8 +1299,7 @@ public void shouldThrowOnNoSuchPaddingExceptionWhenTryingToAESDecrypt() { Assert.assertThrows(IncompatibleDeviceException.class, () -> { doReturn(new byte[]{11, 22, 33}).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenThrow(new NoSuchPaddingException()); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenThrow(new NoSuchPaddingException()); cryptoUtil.decrypt(new byte[0]); }); @@ -1520,8 +1310,7 @@ public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToAESDecrypt() { Assert.assertThrows(IncompatibleDeviceException.class, () -> { doReturn(new byte[]{11, 22, 33}).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenThrow(new NoSuchAlgorithmException()); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenThrow(new NoSuchAlgorithmException()); cryptoUtil.decrypt(new byte[0]); }); @@ -1531,12 +1320,11 @@ public void shouldThrowOnNoSuchAlgorithmExceptionWhenTryingToAESDecrypt() { public void shouldThrowOnEmptyInitializationVectorWhenTryingToAESDecryptWithOldFormat() { Assert.assertThrows("The encryption keys changed recently. You need to re-encrypt something first.", CryptoException.class, () -> { doReturn(new byte[]{11, 22, 33}).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn(""); - PowerMockito.when(storage.retrieveString(BASE_ALIAS + "_iv")).thenReturn(""); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); + Mockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn(""); + Mockito.when(storage.retrieveString(BASE_ALIAS + "_iv")).thenReturn(""); - cryptoUtil.decrypt(new byte[]{12,1,3,14,15,16,17}); + cryptoUtil.decrypt(new byte[]{12, 1, 3, 14, 15, 16, 17}); }); } @@ -1550,16 +1338,14 @@ public void shouldThrowOnInvalidKeyExceptionWhenTryingToAESDecrypt() throws Exce try { doReturn(aesKeyBytes).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("a_valid_iv"); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); + Mockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("a_valid_iv"); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("a_valid_iv", Base64.DEFAULT)).thenReturn(ivBytes); + base64Mock.when(() -> Base64.decode("a_valid_iv", Base64.DEFAULT)).thenReturn(ivBytes); doThrow(new InvalidKeyException()).when(aesCipher).init(eq(Cipher.DECRYPT_MODE), secretKeyArgumentCaptor.capture(), ivParameterSpecArgumentCaptor.capture()); - cryptoUtil.decrypt(new byte[]{12,13,14,15,16}); + cryptoUtil.decrypt(new byte[]{12, 13, 14, 15, 16}); } catch (IncompatibleDeviceException e) { exception = e; } @@ -1580,15 +1366,13 @@ public void shouldThrowOnInvalidAlgorithmParameterExceptionWhenTryingToAESDecryp try { doReturn(aesKeyBytes).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("a_valid_iv"); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); + Mockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("a_valid_iv"); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("a_valid_iv", Base64.DEFAULT)).thenReturn(ivBytes); + base64Mock.when(() -> Base64.decode("a_valid_iv", Base64.DEFAULT)).thenReturn(ivBytes); doThrow(new InvalidAlgorithmParameterException()).when(aesCipher).init(eq(Cipher.DECRYPT_MODE), secretKeyArgumentCaptor.capture(), ivParameterSpecArgumentCaptor.capture()); - cryptoUtil.decrypt(new byte[]{12,13,14,15,16,17}); + cryptoUtil.decrypt(new byte[]{12, 13, 14, 15, 16, 17}); } catch (IncompatibleDeviceException e) { exception = e; } @@ -1606,16 +1390,14 @@ public void shouldThrowButNotDeleteAESKeysOnBadPaddingExceptionWhenTryingToAESDe byte[] ivBytes = new byte[]{99, 22}; doReturn(aesKeyBytes).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("a_valid_iv"); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); + Mockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("a_valid_iv"); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("a_valid_iv", Base64.DEFAULT)).thenReturn(ivBytes); + base64Mock.when(() -> Base64.decode("a_valid_iv", Base64.DEFAULT)).thenReturn(ivBytes); doThrow(new BadPaddingException()).when(aesCipher).doFinal(any(byte[].class)); - cryptoUtil.decrypt(new byte[]{12,13,14,15,16,17}); + cryptoUtil.decrypt(new byte[]{12, 13, 14, 15, 16, 17}); }); Mockito.verify(keyStore, never()).deleteEntry(KEY_ALIAS); @@ -1631,17 +1413,15 @@ public void shouldThrowButNotDeleteAESKeysOnIllegalBlockSizeExceptionWhenTryingT Assert.assertThrows("The AES encrypted input is corrupted and cannot be recovered. Please discard it.", CryptoException.class, () -> { byte[] aesKeyBytes = new byte[]{11, 22, 33}; doReturn(aesKeyBytes).when(cryptoUtil).getAESKey(); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); - PowerMockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("a_valid_iv"); + Mockito.when(Cipher.getInstance(AES_TRANSFORMATION)).thenReturn(aesCipher); + Mockito.when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("a_valid_iv"); byte[] ivBytes = new byte[]{99, 22}; - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode("a_valid_iv", Base64.DEFAULT)).thenReturn(ivBytes); + base64Mock.when(() -> Base64.decode("a_valid_iv", Base64.DEFAULT)).thenReturn(ivBytes); doThrow(new IllegalBlockSizeException()).when(aesCipher).doFinal(any(byte[].class)); - cryptoUtil.decrypt(new byte[]{12,13,14,15,16,17}); + cryptoUtil.decrypt(new byte[]{12, 13, 14, 15, 16, 17}); }); Mockito.verify(keyStore, never()).deleteEntry(KEY_ALIAS); @@ -1653,66 +1433,10 @@ public void shouldThrowButNotDeleteAESKeysOnIllegalBlockSizeExceptionWhenTryingT } - /* - * Helper methods - */ - private KeyPairGeneratorSpec.Builder newKeyPairGeneratorSpecBuilder(KeyPairGeneratorSpec expectedBuilderOutput) { - KeyPairGeneratorSpec.Builder builder = PowerMockito.mock(KeyPairGeneratorSpec.Builder.class); - PowerMockito.when(builder.setAlias(anyString())).thenReturn(builder); - PowerMockito.when(builder.setSubject(any(X500Principal.class))).thenReturn(builder); - PowerMockito.when(builder.setKeySize(anyInt())).thenReturn(builder); - PowerMockito.when(builder.setSerialNumber(any(BigInteger.class))).thenReturn(builder); - PowerMockito.when(builder.setStartDate(any(Date.class))).thenReturn(builder); - PowerMockito.when(builder.setEndDate(any(Date.class))).thenReturn(builder); - PowerMockito.when(builder.setEncryptionRequired()).thenReturn(builder); - PowerMockito.when(builder.build()).thenReturn(expectedBuilderOutput); - return builder; - } - - private KeyGenParameterSpec.Builder newKeyGenParameterSpecBuilder(KeyGenParameterSpec expectedBuilderOutput) { - KeyGenParameterSpec.Builder builder = PowerMockito.mock(KeyGenParameterSpec.Builder.class); - PowerMockito.when(builder.setKeySize(anyInt())).thenReturn(builder); - PowerMockito.when(builder.setCertificateSubject(any(X500Principal.class))).thenReturn(builder); - PowerMockito.when(builder.setCertificateSerialNumber(any(BigInteger.class))).thenReturn(builder); - PowerMockito.when(builder.setCertificateNotBefore(any(Date.class))).thenReturn(builder); - PowerMockito.when(builder.setCertificateNotAfter(any(Date.class))).thenReturn(builder); - //noinspection WrongConstant - PowerMockito.when(builder.setEncryptionPaddings(anyString())).thenReturn(builder); - //noinspection WrongConstant - PowerMockito.when(builder.setDigests(anyString(), anyString())).thenReturn(builder); - //noinspection WrongConstant - PowerMockito.when(builder.setBlockModes(anyString())).thenReturn(builder); - PowerMockito.when(builder.build()).thenReturn(expectedBuilderOutput); - return builder; - } - - private CryptoUtil newCryptoUtilSpy() throws Exception { - CryptoUtil cryptoUtil = PowerMockito.spy(new CryptoUtil(context, storage, BASE_ALIAS)); - PowerMockito.mockStatic(KeyStore.class); - PowerMockito.when(KeyStore.getInstance(ANDROID_KEY_STORE)).thenReturn(keyStore); - PowerMockito.mockStatic(KeyPairGenerator.class); - PowerMockito.when(KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE)).thenReturn(keyPairGenerator); - PowerMockito.mockStatic(KeyGenerator.class); - PowerMockito.when(KeyGenerator.getInstance(ALGORITHM_AES)).thenReturn(keyGenerator); - PowerMockito.mockStatic(Cipher.class); - PowerMockito.when(Cipher.getInstance(anyString())).then((Answer) invocation -> { - String transformation = invocation.getArgument(0, String.class); - if (RSA_TRANSFORMATION.equals(transformation)) { - return rsaOaepCipher; - } else if (OLD_RSA_PKCS1_TRANSFORMATION.equals(transformation)) { - return rsaPkcs1Cipher; - } else if (AES_TRANSFORMATION.equals(transformation)) { - return aesCipher; - } - return null; - }); - return cryptoUtil; - } - @Test public void shouldDetectAndMigratePKCS1KeyToOAEP() throws Exception { CryptoUtil cryptoUtil = newCryptoUtilSpy(); - + byte[] aesKeyBytes = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}; byte[] encryptedAESKeyPKCS1 = new byte[]{20, 21, 22, 23, 24}; byte[] encryptedAESKeyOAEP = new byte[]{30, 31, 32, 33, 34}; @@ -1721,13 +1445,12 @@ public void shouldDetectAndMigratePKCS1KeyToOAEP() throws Exception { when(storage.retrieveString(eq(KEY_ALIAS))).thenReturn(encodedEncryptedAESPKCS1); when(storage.retrieveString(eq(OLD_KEY_ALIAS))).thenReturn(null); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode(encodedEncryptedAESPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESKeyPKCS1); - PowerMockito.when(Base64.encode(encryptedAESKeyOAEP, Base64.DEFAULT)) - .thenReturn(encodedEncryptedAESOAEP.getBytes(StandardCharsets.UTF_8)); + base64Mock.when(() -> Base64.decode(encodedEncryptedAESPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESKeyPKCS1); + base64Mock.when(() -> Base64.encode(encryptedAESKeyOAEP, Base64.DEFAULT)) + .thenReturn(encodedEncryptedAESOAEP.getBytes(StandardCharsets.UTF_8)); IncompatibleDeviceException incompatibleException = new IncompatibleDeviceException( - new KeyStoreException("Incompatible padding mode") + new KeyStoreException("Incompatible padding mode") ); doThrow(incompatibleException).when(cryptoUtil).RSADecrypt(encryptedAESKeyPKCS1); @@ -1740,10 +1463,10 @@ public void shouldDetectAndMigratePKCS1KeyToOAEP() throws Exception { when(mockKeyEntry.getCertificate()).thenReturn(mockCertificate); when(mockCertificate.getPublicKey()).thenReturn(mockPublicKey); when(keyStore.getEntry(eq(KEY_ALIAS), nullable(KeyStore.ProtectionParameter.class))) - .thenReturn(mockKeyEntry); + .thenReturn(mockKeyEntry); when(rsaPkcs1Cipher.doFinal(encryptedAESKeyPKCS1)).thenReturn(aesKeyBytes); - + doReturn(encryptedAESKeyOAEP).when(cryptoUtil).RSAEncrypt(aesKeyBytes); byte[] result = cryptoUtil.getAESKey(); @@ -1760,18 +1483,17 @@ public void shouldDetectAndMigratePKCS1KeyToOAEP() throws Exception { @Test public void shouldHandleKeyStoreErrorDuringMigration() throws Exception { CryptoUtil cryptoUtil = newCryptoUtilSpy(); - + String encodedEncryptedAES = "encrypted_key"; byte[] encryptedAESBytes = new byte[]{5, 6, 7, 8, 9}; - + when(storage.retrieveString(eq(KEY_ALIAS))).thenReturn(encodedEncryptedAES); when(storage.retrieveString(eq(OLD_KEY_ALIAS))).thenReturn(null); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode(encodedEncryptedAES, Base64.DEFAULT)).thenReturn(encryptedAESBytes); + base64Mock.when(() -> Base64.decode(encodedEncryptedAES, Base64.DEFAULT)).thenReturn(encryptedAESBytes); CryptoException cryptoException = new CryptoException( - "Decryption failed", - new ProviderException("KeyStore error code -1000") + "Decryption failed", + new ProviderException("KeyStore error code -1000") ); doThrow(cryptoException).when(cryptoUtil).RSADecrypt(encryptedAESBytes); @@ -1779,12 +1501,12 @@ public void shouldHandleKeyStoreErrorDuringMigration() throws Exception { SecretKey mockSecretKey = mock(SecretKey.class); when(mockSecretKey.getEncoded()).thenReturn(newAESKey); when(keyGenerator.generateKey()).thenReturn(mockSecretKey); - + byte[] encryptedNewKey = new byte[]{30, 31, 32, 33}; doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); String encodedNewKey = "new_generated_key"; - PowerMockito.when(Base64.encode(encryptedNewKey, Base64.DEFAULT)) - .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); byte[] result = cryptoUtil.getAESKey(); @@ -1797,14 +1519,13 @@ public void shouldHandleKeyStoreErrorDuringMigration() throws Exception { @Test public void shouldUseOAEPDirectlyForNewUsers() throws Exception { CryptoUtil cryptoUtil = newCryptoUtilSpy(); - + byte[] aesKeyBytes = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}; byte[] encryptedAESKeyOAEP = new byte[]{20, 21, 22, 23, 24}; String encodedEncryptedAESOAEP = "oaep_encrypted_key"; when(storage.retrieveString(eq(KEY_ALIAS))).thenReturn(encodedEncryptedAESOAEP); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode(encodedEncryptedAESOAEP, Base64.DEFAULT)).thenReturn(encryptedAESKeyOAEP); + base64Mock.when(() -> Base64.decode(encodedEncryptedAESOAEP, Base64.DEFAULT)).thenReturn(encryptedAESKeyOAEP); doReturn(aesKeyBytes).when(cryptoUtil).RSADecrypt(encryptedAESKeyOAEP); @@ -1812,8 +1533,6 @@ public void shouldUseOAEPDirectlyForNewUsers() throws Exception { assertThat(result, is(aesKeyBytes)); - verifyPrivate(cryptoUtil).invoke("RSADecrypt", encryptedAESKeyOAEP); - Mockito.verify(rsaPkcs1Cipher, never()).init(anyInt(), any(PrivateKey.class)); Mockito.verify(rsaPkcs1Cipher, never()).doFinal(any(byte[].class)); @@ -1823,20 +1542,19 @@ public void shouldUseOAEPDirectlyForNewUsers() throws Exception { @Test public void shouldRecognizeIncompatiblePaddingModeInExceptionChain() throws Exception { CryptoUtil cryptoUtil = newCryptoUtilSpy(); - + String encodedEncryptedAES = "encrypted_key"; byte[] encryptedAESBytes = new byte[]{5, 6, 7, 8}; - + when(storage.retrieveString(eq(KEY_ALIAS))).thenReturn(encodedEncryptedAES); when(storage.retrieveString(eq(OLD_KEY_ALIAS))).thenReturn(null); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode(encodedEncryptedAES, Base64.DEFAULT)).thenReturn(encryptedAESBytes); + base64Mock.when(() -> Base64.decode(encodedEncryptedAES, Base64.DEFAULT)).thenReturn(encryptedAESBytes); ProviderException rootCause = new ProviderException("Incompatible padding mode"); IllegalBlockSizeException middleException = new IllegalBlockSizeException("Encryption failed"); middleException.initCause(rootCause); IncompatibleDeviceException topException = new IncompatibleDeviceException(middleException); - + doThrow(topException).when(cryptoUtil).RSADecrypt(encryptedAESBytes); when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); @@ -1844,28 +1562,28 @@ public void shouldRecognizeIncompatiblePaddingModeInExceptionChain() throws Exce PrivateKey mockPrivateKey = mock(PrivateKey.class); when(mockKeyEntry.getPrivateKey()).thenReturn(mockPrivateKey); when(keyStore.getEntry(eq(KEY_ALIAS), nullable(KeyStore.ProtectionParameter.class))) - .thenReturn(mockKeyEntry); - + .thenReturn(mockKeyEntry); + byte[] aesKeyBytes = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}; when(rsaPkcs1Cipher.doFinal(encryptedAESBytes)).thenReturn(aesKeyBytes); - + byte[] encryptedAESKeyOAEP = new byte[]{20, 21, 22, 23}; doReturn(encryptedAESKeyOAEP).when(cryptoUtil).RSAEncrypt(aesKeyBytes); String encodedOAEP = "oaep_key"; - PowerMockito.when(Base64.encode(encryptedAESKeyOAEP, Base64.DEFAULT)) - .thenReturn(encodedOAEP.getBytes(StandardCharsets.UTF_8)); + base64Mock.when(() -> Base64.encode(encryptedAESKeyOAEP, Base64.DEFAULT)) + .thenReturn(encodedOAEP.getBytes(StandardCharsets.UTF_8)); byte[] result = cryptoUtil.getAESKey(); assertThat(result, is(aesKeyBytes)); Mockito.verify(rsaPkcs1Cipher).doFinal(encryptedAESBytes); - + } @Test public void shouldAllowMultipleRetrievalsAfterMigration() throws Exception { - + CryptoUtil cryptoUtil = newCryptoUtilSpy(); - + byte[] aesKeyBytes = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}; byte[] encryptedAESKeyPKCS1 = new byte[]{20, 21, 22, 23, 24}; byte[] encryptedAESKeyOAEP = new byte[]{30, 31, 32, 33, 34}; @@ -1875,13 +1593,12 @@ public void shouldAllowMultipleRetrievalsAfterMigration() throws Exception { // First retrieval - migration happens, returns decrypted key when(storage.retrieveString(eq(KEY_ALIAS))).thenReturn(encodedEncryptedAESPKCS1); when(storage.retrieveString(eq(OLD_KEY_ALIAS))).thenReturn(null); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode(encodedEncryptedAESPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESKeyPKCS1); - PowerMockito.when(Base64.encode(encryptedAESKeyOAEP, Base64.DEFAULT)) - .thenReturn(encodedEncryptedAESOAEP.getBytes(StandardCharsets.UTF_8)); + base64Mock.when(() -> Base64.decode(encodedEncryptedAESPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESKeyPKCS1); + base64Mock.when(() -> Base64.encode(encryptedAESKeyOAEP, Base64.DEFAULT)) + .thenReturn(encodedEncryptedAESOAEP.getBytes(StandardCharsets.UTF_8)); IncompatibleDeviceException incompatibleException = new IncompatibleDeviceException( - new KeyStoreException("Incompatible padding mode") + new KeyStoreException("Incompatible padding mode") ); doThrow(incompatibleException).when(cryptoUtil).RSADecrypt(encryptedAESKeyPKCS1); @@ -1894,16 +1611,16 @@ public void shouldAllowMultipleRetrievalsAfterMigration() throws Exception { when(mockKeyEntry.getCertificate()).thenReturn(mockCertificate); when(mockCertificate.getPublicKey()).thenReturn(mockPublicKey); when(keyStore.getEntry(eq(KEY_ALIAS), nullable(KeyStore.ProtectionParameter.class))) - .thenReturn(mockKeyEntry); + .thenReturn(mockKeyEntry); when(rsaPkcs1Cipher.doFinal(encryptedAESKeyPKCS1)).thenReturn(aesKeyBytes); - + // Mock RSAEncrypt for re-encrypting with OAEP after migration doReturn(encryptedAESKeyOAEP).when(cryptoUtil).RSAEncrypt(aesKeyBytes); byte[] result1 = cryptoUtil.getAESKey(); assertThat(result1, is(aesKeyBytes)); - + // Migration should delete old keys and store re-encrypted AES key Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); Mockito.verify(storage).store(KEY_ALIAS, encodedEncryptedAESOAEP); @@ -1912,28 +1629,27 @@ public void shouldAllowMultipleRetrievalsAfterMigration() throws Exception { @Test public void shouldGenerateNewKeyWhenMigrationFails() throws Exception { CryptoUtil cryptoUtil = newCryptoUtilSpy(); - + String encodedOldKey = "corrupted_old_key"; byte[] encryptedOldKey = new byte[]{5, 6, 7}; - + when(storage.retrieveString(eq(KEY_ALIAS))).thenReturn(null); when(storage.retrieveString(eq(OLD_KEY_ALIAS))).thenReturn(encodedOldKey); - PowerMockito.mockStatic(Base64.class); - PowerMockito.when(Base64.decode(encodedOldKey, Base64.DEFAULT)).thenReturn(encryptedOldKey); + base64Mock.when(() -> Base64.decode(encodedOldKey, Base64.DEFAULT)).thenReturn(encryptedOldKey); doThrow(new CryptoException("Key corrupted", new KeyStoreException("Entry not found"))) - .when(cryptoUtil).getRSAKeyEntry(); + .when(cryptoUtil).getRSAKeyEntry(); byte[] newAESKey = new byte[]{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36}; SecretKey mockSecretKey = mock(SecretKey.class); when(mockSecretKey.getEncoded()).thenReturn(newAESKey); when(keyGenerator.generateKey()).thenReturn(mockSecretKey); - + byte[] encryptedNewKey = new byte[]{40, 41, 42}; doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); String encodedNewKey = "fresh_key"; - PowerMockito.when(Base64.encode(encryptedNewKey, Base64.DEFAULT)) - .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); byte[] result = cryptoUtil.getAESKey(); assertThat(result, is(newAESKey)); Mockito.verify(storage).store(KEY_ALIAS, encodedNewKey); @@ -1941,4 +1657,354 @@ public void shouldGenerateNewKeyWhenMigrationFails() throws Exception { Mockito.verify(storage, times(1)).remove(KEY_ALIAS); Mockito.verify(storage, times(1)).remove(OLD_KEY_ALIAS); } + + @Test + public void shouldWrapProviderExceptionFromCipherInitInRSADecryptAsCryptoException() { + Assert.assertThrows("The RSA key's padding mode is incompatible with the current cipher.", + CryptoException.class, () -> { + PrivateKey privateKey = mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = mock(KeyStore.PrivateKeyEntry.class); + doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + doThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))) + .when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), + any(AlgorithmParameterSpec.class)); + + cryptoUtil.RSADecrypt(new byte[]{1, 2, 3}); + }); + } + + @Test + public void shouldWrapProviderExceptionFromCipherInitInRSAEncryptAsCryptoException() { + Assert.assertThrows("The RSA key's padding mode is incompatible with the current cipher.", + CryptoException.class, () -> { + PublicKey publicKey = mock(PublicKey.class); + Certificate certificate = mock(Certificate.class); + doReturn(publicKey).when(certificate).getPublicKey(); + KeyStore.PrivateKeyEntry privateKeyEntry = mock(KeyStore.PrivateKeyEntry.class); + doReturn(certificate).when(privateKeyEntry).getCertificate(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + doThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))) + .when(rsaOaepCipher).init(eq(Cipher.ENCRYPT_MODE), eq(publicKey), + any(AlgorithmParameterSpec.class)); + + cryptoUtil.RSAEncrypt(new byte[]{1, 2, 3}); + }); + } + + @Test + public void shouldTriggerPKCS1MigrationWhenRSADecryptThrowsProviderException() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 0xAB); + byte[] reEncryptedOAEP = new byte[]{20, 21, 22, 23}; + String encodedPKCS1 = "pkcs1_encoded"; + String encodedOAEP = "oaep_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + base64Mock.when(() -> Base64.encode(reEncryptedOAEP, Base64.DEFAULT)) + .thenReturn(encodedOAEP.getBytes(StandardCharsets.UTF_8)); + + doThrow(new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSADecrypt(encryptedAESPKCS1); + + when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); + KeyStore.PrivateKeyEntry mockEntry = mock(KeyStore.PrivateKeyEntry.class); + when(mockEntry.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(keyStore.getEntry(eq(KEY_ALIAS), nullable(KeyStore.ProtectionParameter.class))) + .thenReturn(mockEntry); + when(rsaPkcs1Cipher.doFinal(encryptedAESPKCS1)).thenReturn(aesKeyBytes); + doReturn(reEncryptedOAEP).when(cryptoUtil).RSAEncrypt(aesKeyBytes); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(aesKeyBytes)); + Mockito.verify(storage).store(KEY_ALIAS, encodedOAEP); + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + } + + @Test + public void shouldDeleteOldRSAKeyBeforeReEncryptingInTryMigrateLegacyAESKey() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 0xCD); + byte[] encryptedOldAES = new byte[]{1, 2, 3, 4}; + byte[] encryptedNewAES = new byte[]{4, 5, 6}; + String encodedOldAES = "old_pkcs1_encoded"; + String encodedNewAES = "new_oaep_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(encodedOldAES); + + base64Mock.when(() -> Base64.decode(encodedOldAES, Base64.DEFAULT)).thenReturn(encryptedOldAES); + base64Mock.when(() -> Base64.encode(encryptedNewAES, Base64.DEFAULT)) + .thenReturn(encodedNewAES.getBytes(StandardCharsets.UTF_8)); + + KeyStore.PrivateKeyEntry mockEntry = mock(KeyStore.PrivateKeyEntry.class); + PrivateKey mockPrivateKey = mock(PrivateKey.class); + when(mockEntry.getPrivateKey()).thenReturn(mockPrivateKey); + doReturn(mockEntry).when(cryptoUtil).getRSAKeyEntry(); + + when(rsaPkcs1Cipher.doFinal(encryptedOldAES)).thenReturn(aesKeyBytes); + + doReturn(encryptedNewAES).when(cryptoUtil).RSAEncrypt(aesKeyBytes); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(aesKeyBytes)); + Mockito.verify(storage).store(KEY_ALIAS, encodedNewAES); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); + + InOrder inOrder = Mockito.inOrder(keyStore, cryptoUtil); + inOrder.verify(keyStore).deleteEntry(KEY_ALIAS); + inOrder.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + inOrder.verify(cryptoUtil).RSAEncrypt(aesKeyBytes); + } + + @Test + public void shouldDeleteStaleRSAKeyAndRethrowOnIncompatibleDeviceExceptionDuringGenerateNewAESKey() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xEF); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + doThrow(new IncompatibleDeviceException( + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSAEncrypt(newAESKey); + + Assert.assertThrows(IncompatibleDeviceException.class, () -> cryptoUtil.getAESKey()); + + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); + } + + @Test + public void shouldHandleProviderExceptionInAttemptPKCS1Migration() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + String encodedPKCS1 = "pkcs1_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + + doThrow(new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSADecrypt(encryptedAESPKCS1); + + when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); + KeyStore.PrivateKeyEntry mockEntry = mock(KeyStore.PrivateKeyEntry.class); + when(mockEntry.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(keyStore.getEntry(eq(KEY_ALIAS), nullable(KeyStore.ProtectionParameter.class))) + .thenReturn(mockEntry); + when(rsaPkcs1Cipher.doFinal(encryptedAESPKCS1)) + .thenThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))); + + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xDD); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + byte[] encryptedNewKey = new byte[]{30, 31, 32, 33}; + doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); + String encodedNewKey = "new_key_encoded"; + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(newAESKey)); + Mockito.verify(storage).store(KEY_ALIAS, encodedNewKey); + } + + @Test + public void shouldHandleProviderExceptionInTryMigrateLegacyAESKey() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); + + String encodedOldAES = "old_legacy_key"; + byte[] encryptedOldAES = new byte[]{1, 2, 3, 4}; + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(encodedOldAES); + + base64Mock.when(() -> Base64.decode(encodedOldAES, Base64.DEFAULT)).thenReturn(encryptedOldAES); + + KeyStore.PrivateKeyEntry mockEntry = mock(KeyStore.PrivateKeyEntry.class); + PrivateKey mockPrivateKey = mock(PrivateKey.class); + when(mockEntry.getPrivateKey()).thenReturn(mockPrivateKey); + doReturn(mockEntry).when(cryptoUtil).getRSAKeyEntry(); + + when(rsaPkcs1Cipher.doFinal(encryptedOldAES)) + .thenThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xEE); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + byte[] encryptedNewKey = new byte[]{40, 41, 42, 43}; + doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); + String encodedNewKey = "new_generated_key"; + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(newAESKey)); + Mockito.verify(storage).store(KEY_ALIAS, encodedNewKey); + } + + @Test + public void shouldFallThroughToKeyRegenerationWhenMigrationFailsWithCryptoException() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + String encodedPKCS1 = "pkcs1_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + + doThrow(new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSADecrypt(encryptedAESPKCS1); + + when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); + when(keyStore.containsAlias(OLD_KEY_ALIAS)).thenReturn(false); + + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xFF); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + byte[] encryptedNewKey = new byte[]{50, 51, 52, 53}; + doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); + String encodedNewKey = "regenerated_key"; + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(newAESKey)); + Mockito.verify(storage).store(KEY_ALIAS, encodedNewKey); + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + } + + @Test + public void shouldNotPropagateProviderExceptionAsIncompatibleDeviceException() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + String encodedPKCS1 = "pkcs1_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + + doThrow(new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSADecrypt(encryptedAESPKCS1); + + when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); + when(keyStore.containsAlias(OLD_KEY_ALIAS)).thenReturn(false); + + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xAA); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + byte[] encryptedNewKey = new byte[]{60, 61, 62, 63}; + doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); + String encodedNewKey = "recovered_key"; + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(notNullValue())); + assertThat(result, is(newAESKey)); + } + + /* + * deleteAllKeys() tests + */ + + @Test + public void shouldDeleteBothRSAAndAESKeysWhenDeleteAllKeysIsCalled() throws Exception { + cryptoUtil.deleteAllKeys(); + + // Verify RSA keys deleted from KeyStore + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + + // Verify AES keys deleted from Storage + Mockito.verify(storage).remove(KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS + "_iv"); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(OLD_KEY_ALIAS + "_iv"); + } + + @Test + public void shouldDeleteAESKeysEvenIfRSAKeyDeletionFails() throws Exception { + doThrow(new KeyStoreException("KeyStore error")).when(keyStore).deleteEntry(anyString()); + + cryptoUtil.deleteAllKeys(); + + // RSA deletion was attempted (first deleteEntry throws, second is never reached) + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + + // AES keys should still be deleted from Storage + Mockito.verify(storage).remove(KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS + "_iv"); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(OLD_KEY_ALIAS + "_iv"); + } + + /* + * Helper methods + */ + private CryptoUtil newCryptoUtilSpy() throws Exception { + CryptoUtil cryptoUtil = Mockito.spy(new CryptoUtil(context, storage, BASE_ALIAS)); + Mockito.when(KeyStore.getInstance(ANDROID_KEY_STORE)).thenReturn(keyStore); + Mockito.when(KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE)).thenReturn(keyPairGenerator); + Mockito.when(KeyGenerator.getInstance(ALGORITHM_AES)).thenReturn(keyGenerator); + Mockito.when(Cipher.getInstance(anyString())).then((Answer) invocation -> { + String transformation = invocation.getArgument(0, String.class); + if (RSA_TRANSFORMATION.equals(transformation)) { + return rsaOaepCipher; + } else if (OLD_RSA_PKCS1_TRANSFORMATION.equals(transformation)) { + return rsaPkcs1Cipher; + } else if (AES_TRANSFORMATION.equals(transformation)) { + return aesCipher; + } + return null; + }); + return cryptoUtil; + } } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt index 53c14baf1..91f1ea557 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt @@ -4,10 +4,10 @@ import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.fragment.app.FragmentActivity import com.auth0.android.callback.Callback -import com.nhaarman.mockitokotlin2.KArgumentCaptor -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.verify +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerBiometricPolicyTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerBiometricPolicyTest.kt index db478ec2e..d5f19678e 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerBiometricPolicyTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerBiometricPolicyTest.kt @@ -6,7 +6,7 @@ import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.callback.Callback import com.auth0.android.result.Credentials import com.auth0.android.util.Clock -import com.nhaarman.mockitokotlin2.* +import org.mockito.kotlin.* import org.junit.After import org.junit.Before import org.junit.Test @@ -270,7 +270,7 @@ public class SecureCredentialsManagerBiometricPolicyTest { // Clear credentials manager.clearCredentials() - verify(mockStorage, atLeastOnce()).remove(any()) + verify(mockStorage).removeAll() // Session should be invalid assert(!manager.isBiometricSessionValid()) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 078690cc3..67c328613 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -5,11 +5,15 @@ import android.app.KeyguardManager import android.content.Context import android.util.Base64 import androidx.fragment.app.FragmentActivity -import com.auth0.android.Auth0 import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.storage.BaseCredentialsManager.Companion.DEFAULT_MIN_TTL import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt @@ -22,15 +26,6 @@ import com.auth0.android.result.SSOCredentialsMock import com.auth0.android.result.toAPICredentials import com.auth0.android.util.Clock import com.google.gson.Gson -import com.nhaarman.mockitokotlin2.KArgumentCaptor -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -38,6 +33,7 @@ import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is import org.hamcrest.core.IsInstanceOf +import org.junit.After import org.junit.Assert import org.junit.Assert.assertThrows import org.junit.Before @@ -52,6 +48,17 @@ import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import java.lang.ref.WeakReference @@ -103,6 +110,10 @@ public class SecureCredentialsManagerTest { private lateinit var fragmentActivity: FragmentActivity + private lateinit var mockDPoPKeyStore: DPoPKeyStore + private val fakePublicKey = FakeECPublicKey() + private val fakePrivateKey = FakeECPrivateKey() + private val serialExecutor = Executor { runnable -> runnable.run() } private val credentialsCaptor: KArgumentCaptor = argumentCaptor() @@ -117,11 +128,13 @@ public class SecureCredentialsManagerTest { public val exception: ExpectedException = ExpectedException.none() private lateinit var manager: SecureCredentialsManager private lateinit var gson: Gson - private lateinit var auth0: Auth0 @Before public fun setUp() { MockitoAnnotations.openMocks(this) + mockDPoPKeyStore = mock() + DPoPUtil.keyStore = mockDPoPKeyStore + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) val activity = Robolectric.buildActivity(Activity::class.java).create().start().resume().get() val activityContext = Mockito.spy(activity) @@ -140,8 +153,7 @@ public class SecureCredentialsManagerTest { .get() ) weakFragmentActivity = WeakReference(fragmentActivity) - auth0 = Mockito.spy(Auth0.getInstance("clientId", "domain")) - Mockito.`when`(auth0.executor).thenReturn(serialExecutor) + Mockito.`when`(client.executor).thenReturn(serialExecutor) val secureCredentialsManager = SecureCredentialsManager( @@ -149,7 +161,7 @@ public class SecureCredentialsManagerTest { storage, crypto, jwtDecoder, - auth0.executor, + serialExecutor, weakFragmentActivity, getAuthenticationOptions(), factory @@ -165,8 +177,8 @@ public class SecureCredentialsManagerTest { Robolectric.buildActivity(Activity::class.java).create().start().resume().get() val storage: Storage = SharedPreferencesStorage(context) val manager = SecureCredentialsManager( + client, context, - auth0, storage, fragmentActivity, getAuthenticationOptions() @@ -182,8 +194,12 @@ public class SecureCredentialsManagerTest { public fun shouldNotSaveIfThereIsErrorInGettingTheExistingCredentials() { verifyNoMoreInteractions(storage) val ssoCredentials = SSOCredentialsMock.create( - "accessToken", "identityToken", - "issuedTokenType", "tokenType", "refresh_token", 60 + "accessToken", + "identityToken", + "issuedTokenType", + "tokenType", + "refresh_token", + Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) ) val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) val storedJson = insertTestCredentials( @@ -204,8 +220,12 @@ public class SecureCredentialsManagerTest { public fun shouldSaveIfTheNewSSOCredentialRefreshAndIdTokenIsNotSameAsTheExistingOne() { verifyNoMoreInteractions(storage) val sessionTransferCredentials = SSOCredentialsMock.create( - "accessToken", "identityToken", - "issuedTokenType", "tokenType", "refresh_token", 60 + "accessToken", + "identityToken", + "issuedTokenType", + "tokenType", + "refresh_token", + Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) ) val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) insertTestCredentials( @@ -306,6 +326,7 @@ public class SecureCredentialsManagerTest { val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) Mockito.`when`(client.ssoExchange("refreshToken")) .thenReturn(SSOCredentialsRequest) + val ssoExpiresAt = Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) Mockito.`when`(SSOCredentialsRequest.execute()).thenReturn( SSOCredentialsMock.create( "web-sso-token", @@ -313,7 +334,7 @@ public class SecureCredentialsManagerTest { "issued-token-type", "token-type", "refresh-token", - 60 + ssoExpiresAt ) ) insertTestCredentials( @@ -343,7 +364,7 @@ public class SecureCredentialsManagerTest { MatcherAssert.assertThat(credentials.tokenType, Is.`is`("token-type")) MatcherAssert.assertThat(credentials.issuedTokenType, Is.`is`("issued-token-type")) MatcherAssert.assertThat(credentials.refreshToken, Is.`is`("refresh-token")) - MatcherAssert.assertThat(credentials.expiresIn, Is.`is`(60)) + MatcherAssert.assertThat(credentials.expiresAt, Is.`is`(ssoExpiresAt)) verify(storage).store(eq("com.auth0.credentials"), stringCaptor.capture()) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -487,6 +508,7 @@ public class SecureCredentialsManagerTest { val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) Mockito.`when`(client.ssoExchange("refreshToken")) .thenReturn(SSOCredentialsRequest) + val ssoExpiresAt = Date(CredentialsMock.CURRENT_TIME_MS + 60 * 1000) Mockito.`when`(SSOCredentialsRequest.execute()).thenReturn( SSOCredentialsMock.create( "web-sso-token", @@ -494,7 +516,7 @@ public class SecureCredentialsManagerTest { "issued-token-type", "token-type", "refresh-token", - 60 + ssoExpiresAt ) ) insertTestCredentials( @@ -522,7 +544,7 @@ public class SecureCredentialsManagerTest { MatcherAssert.assertThat(credentials.tokenType, Is.`is`("token-type")) MatcherAssert.assertThat(credentials.issuedTokenType, Is.`is`("issued-token-type")) MatcherAssert.assertThat(credentials.refreshToken, Is.`is`("refresh-token")) - MatcherAssert.assertThat(credentials.expiresIn, Is.`is`(60)) + MatcherAssert.assertThat(credentials.expiresAt, Is.`is`(ssoExpiresAt)) verify(storage).store(eq("com.auth0.credentials"), stringCaptor.capture()) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -612,6 +634,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", sharedExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -647,6 +671,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -686,6 +712,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -725,6 +753,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", expirationTime) verify(storage).store("com.auth0.credentials_can_refresh", false) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -766,9 +796,7 @@ public class SecureCredentialsManagerTest { exception.message, Is.`is`("A change on the Lock Screen security settings have deemed the encryption keys invalid and have been recreated. Any previously stored content is now lost. Please try saving the credentials again.") ) - verify(storage).remove("com.auth0.credentials") - verify(storage).remove("com.auth0.credentials_expires_at") - verify(storage).remove("com.auth0.credentials_can_refresh") + verify(storage).removeAll() } @Test @@ -863,9 +891,7 @@ public class SecureCredentialsManagerTest { "Any previously stored content is now lost. Please try saving the credentials again." ) ) - verify(storage).remove("com.auth0.credentials") - verify(storage).remove("com.auth0.credentials_expires_at") - verify(storage).remove("com.auth0.credentials_can_refresh") + verify(storage).removeAll() } @Test @@ -1245,7 +1271,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1369,7 +1395,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1437,7 +1463,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1507,7 +1533,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1592,7 +1618,7 @@ public class SecureCredentialsManagerTest { .store(eq("com.auth0.credentials"), stringCaptor.capture()) verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1656,7 +1682,7 @@ public class SecureCredentialsManagerTest { .store(eq("com.auth0.credentials"), stringCaptor.capture()) verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1910,11 +1936,20 @@ public class SecureCredentialsManagerTest { MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) - - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload, + Is.`is`(Matchers.notNullValue()) + ) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("test-mfa-token-12345")) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(2)) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, + Is.`is`(Matchers.notNullValue()) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, + Is.`is`(2) + ) } @Test @@ -1947,9 +1982,18 @@ public class SecureCredentialsManagerTest { val exception = exceptionCaptor.firstValue MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("enroll-mfa-token")) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(3)) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.nullValue())) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, + Is.`is`(Matchers.notNullValue()) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, + Is.`is`(3) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, + Is.`is`(Matchers.nullValue()) + ) } @Test @@ -1974,7 +2018,10 @@ public class SecureCredentialsManagerTest { verify(callback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue - MatcherAssert.assertThat(exception.message, Matchers.containsString("processing the request")) + MatcherAssert.assertThat( + exception.message, + Matchers.containsString("processing the request") + ) MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.nullValue())) MatcherAssert.assertThat(exception.mfaToken, Is.`is`(Matchers.nullValue())) } @@ -2007,7 +2054,10 @@ public class SecureCredentialsManagerTest { } MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload, + Is.`is`(Matchers.notNullValue()) + ) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("await-mfa-token-12345")) } @@ -2032,22 +2082,27 @@ public class SecureCredentialsManagerTest { verify(callback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue - + MatcherAssert.assertThat(exception.cause, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.cause, IsInstanceOf.instanceOf(AuthenticationException::class.java)) - + MatcherAssert.assertThat( + exception.cause, + IsInstanceOf.instanceOf(AuthenticationException::class.java) + ) + val causeException = exception.cause as AuthenticationException MatcherAssert.assertThat(causeException.getCode(), Is.`is`("mfa_required")) MatcherAssert.assertThat(causeException.isMultifactorRequired, Is.`is`(true)) - MatcherAssert.assertThat(causeException.getDescription(), Is.`is`("MFA is required for this action")) + MatcherAssert.assertThat( + causeException.getDescription(), + Is.`is`("MFA is required for this action") + ) } /** - * Testing that getCredentials execution from multiple threads via multiple instances of SecureCredentialsManager should trigger only one network request. + * Testing that getCredentials execution from multiple threads via multiple instances of SecureCredentialsManager should trigger only one network request. */ @Test public fun shouldSynchronizeGetCredentialsAccessAcrossThreadsAndInstances() { - val expiredCredentials = Credentials( "", "accessToken", @@ -2056,23 +2111,20 @@ public class SecureCredentialsManagerTest { Date(CredentialsMock.CURRENT_TIME_MS), "scope" ) - val renewedCredentials = - Credentials( - "newId", - "newAccess", - "newType", - "rotatedRefreshToken", - Date(CredentialsMock.ONE_HOUR_AHEAD_MS), - "newScope" - ) - Mockito.`when`( - client.renewAuth(refreshToken = "refreshToken") - ).thenReturn(request) + val renewedCredentials = Credentials( + "newId", + "newAccess", + "newType", + "rotatedRefreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + "newScope" + ) + Mockito.`when`(client.renewAuth(refreshToken = "refreshToken")).thenReturn(request) Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - val serialExecutor = Executors.newSingleThreadExecutor() - Mockito.`when`(auth0.executor).thenReturn(serialExecutor) - val executor: ExecutorService = Executors.newFixedThreadPool(5) - val latch = CountDownLatch(5) + + val sharedExecutor = Executors.newSingleThreadExecutor() + val callerPool: ExecutorService = Executors.newFixedThreadPool(3) + val latch = CountDownLatch(3) val context: Context = Robolectric.buildActivity(Activity::class.java).create().start().resume().get() val storage = SharedPreferencesStorage( @@ -2080,67 +2132,228 @@ public class SecureCredentialsManagerTest { sharedPreferencesName = "com.auth0.android.storage.SecureCredentialsManagerTest" ) val cryptoMock = Mockito.mock(CryptoUtil::class.java) - Mockito.`when`(cryptoMock.encrypt(any())).thenAnswer { - val input = it.arguments[0] as ByteArray - input - } - Mockito.`when`(cryptoMock.decrypt(any())).thenAnswer { - val input = it.arguments[0] as ByteArray - input - } - val secureCredsManager = - SecureCredentialsManager(client, storage, cryptoMock, jwtDecoder, auth0.executor) - secureCredsManager.saveCredentials(expiredCredentials) - repeat(5) { - executor.submit { - val secureCredsManager = - SecureCredentialsManager( - client, - storage, - cryptoMock, - jwtDecoder, - auth0.executor, - ) - secureCredsManager.getCredentials(object : + Mockito.`when`(cryptoMock.encrypt(any())).thenAnswer { it.arguments[0] as ByteArray } + Mockito.`when`(cryptoMock.decrypt(any())).thenAnswer { it.arguments[0] as ByteArray } + + SecureCredentialsManager( + client, storage, cryptoMock, jwtDecoder, sharedExecutor + ).saveCredentials(expiredCredentials) + + repeat(3) { + callerPool.submit { + // All instances share the same executor — operations are globally serialized + val instance = SecureCredentialsManager( + client, storage, cryptoMock, jwtDecoder, sharedExecutor + ) + instance.getCredentials(object : + Callback { + override fun onFailure(error: CredentialsManagerException) { + throw error + } + + override fun onSuccess(result: Credentials) { + MatcherAssert.assertThat( + result.accessToken, Is.`is`(renewedCredentials.accessToken) + ) + MatcherAssert.assertThat( + result.refreshToken, Is.`is`(renewedCredentials.refreshToken) + ) + latch.countDown() + } + }) + } + } + + Assert.assertTrue(latch.await(3, TimeUnit.SECONDS)) + // Exactly one renewal — the shared executor serialized all 5 instances + Mockito.verify(client, Mockito.times(1)).renewAuth(refreshToken = "refreshToken") + Mockito.verify(request, Mockito.times(1)).execute() + } + + @Test + public fun shouldTriggerOnlyOneRenewalWhenMultipleThreadsCallGetCredentialsOnSameInstance() { + val expiredCredentials = Credentials( + "", + "accessToken", + "type", + "refreshToken", + Date(CredentialsMock.CURRENT_TIME_MS), + "scope" + ) + val renewedCredentials = Credentials( + "newId", + "newAccess", + "newType", + "rotatedRefreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + "newScope" + ) + Mockito.`when`(client.renewAuth(refreshToken = "refreshToken")).thenReturn(request) + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + + val singleThreadExecutor = Executors.newSingleThreadExecutor() + val callerPool = Executors.newFixedThreadPool(3) + val latch = CountDownLatch(3) + val context: Context = + Robolectric.buildActivity(Activity::class.java).create().start().resume().get() + val storage = SharedPreferencesStorage( + context = context, + sharedPreferencesName = "com.auth0.android.storage.SecureCredentialsManagerTest.singleInstance" + ) + val cryptoMock = Mockito.mock(CryptoUtil::class.java) + Mockito.`when`(cryptoMock.encrypt(any())).thenAnswer { it.arguments[0] as ByteArray } + Mockito.`when`(cryptoMock.decrypt(any())).thenAnswer { it.arguments[0] as ByteArray } + + val singleManager = + SecureCredentialsManager(client, storage, cryptoMock, jwtDecoder, singleThreadExecutor) + singleManager.saveCredentials(expiredCredentials) + + repeat(3) { + callerPool.submit { + singleManager.getCredentials(object : Callback { override fun onFailure(error: CredentialsManagerException) { throw error } override fun onSuccess(result: Credentials) { - // Verify all instances retrieved the same credentials MatcherAssert.assertThat( - renewedCredentials.accessToken, - Is.`is`(result.accessToken) + result.accessToken, + Is.`is`(renewedCredentials.accessToken) ) MatcherAssert.assertThat( - renewedCredentials.idToken, - Is.`is`(result.idToken) + result.refreshToken, + Is.`is`(renewedCredentials.refreshToken) ) + latch.countDown() + } + }) + } + } + + Assert.assertTrue(latch.await(3, TimeUnit.SECONDS)) + Mockito.verify(client, Mockito.times(1)).renewAuth(refreshToken = "refreshToken") + Mockito.verify(request, Mockito.times(1)).execute() + } + + @Test + public fun shouldNotTriggerRenewalWhenMultipleThreadsAccessValidCredentialsOnSameInstance() { + val validCredentials = Credentials( + "idToken", + "accessToken", + "type", + "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + "scope" + ) + + val singleThreadExecutor = Executors.newSingleThreadExecutor() + val callerPool = Executors.newFixedThreadPool(3) + val latch = CountDownLatch(3) + val context: Context = + Robolectric.buildActivity(Activity::class.java).create().start().resume().get() + val storage = SharedPreferencesStorage( + context = context, + sharedPreferencesName = "com.auth0.android.storage.SecureCredentialsManagerTest.validCreds" + ) + val cryptoMock = Mockito.mock(CryptoUtil::class.java) + Mockito.`when`(cryptoMock.encrypt(any())).thenAnswer { it.arguments[0] as ByteArray } + Mockito.`when`(cryptoMock.decrypt(any())).thenAnswer { it.arguments[0] as ByteArray } + + val singleManager = + SecureCredentialsManager(client, storage, cryptoMock, jwtDecoder, singleThreadExecutor) + singleManager.saveCredentials(validCredentials) + + repeat(3) { + callerPool.submit { + singleManager.getCredentials(object : + Callback { + override fun onFailure(error: CredentialsManagerException) { + throw error + } + + override fun onSuccess(result: Credentials) { MatcherAssert.assertThat( - renewedCredentials.refreshToken, - Is.`is`(result.refreshToken) + result.accessToken, + Is.`is`(validCredentials.accessToken) ) - MatcherAssert.assertThat(renewedCredentials.type, Is.`is`(result.type)) + latch.countDown() + } + }) + } + } + + Assert.assertTrue(latch.await(3, TimeUnit.SECONDS)) + Mockito.verify(client, Mockito.never()).renewAuth(any(), anyOrNull(), anyOrNull()) + } + + + @Test + public fun shouldNotTriggerSecondRenewalForCallerQueuedBehindFirstRenewal() { + val expiredCredentials = Credentials( + "", + "accessToken", + "type", + "refreshToken", + Date(CredentialsMock.CURRENT_TIME_MS), + "scope" + ) + val renewedCredentials = Credentials( + "newId", + "newAccess", + "newType", + "rotatedRefreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + "newScope" + ) + Mockito.`when`(client.renewAuth(refreshToken = "refreshToken")).thenReturn(request) + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + + val singleThreadExecutor = Executors.newSingleThreadExecutor() + val callerPool = Executors.newFixedThreadPool(2) + val latch = CountDownLatch(2) + val context: Context = + Robolectric.buildActivity(Activity::class.java).create().start().resume().get() + val storage = SharedPreferencesStorage( + context = context, + sharedPreferencesName = "com.auth0.android.storage.SecureCredentialsManagerTest.queuedCaller" + ) + val cryptoMock = Mockito.mock(CryptoUtil::class.java) + Mockito.`when`(cryptoMock.encrypt(any())).thenAnswer { it.arguments[0] as ByteArray } + Mockito.`when`(cryptoMock.decrypt(any())).thenAnswer { it.arguments[0] as ByteArray } + + val singleManager = + SecureCredentialsManager(client, storage, cryptoMock, jwtDecoder, singleThreadExecutor) + singleManager.saveCredentials(expiredCredentials) + + repeat(2) { + callerPool.submit { + singleManager.getCredentials(object : + Callback { + override fun onFailure(error: CredentialsManagerException) { + throw error + } + + override fun onSuccess(result: Credentials) { + // Both callers must receive the renewed credentials, not the expired ones. MatcherAssert.assertThat( - renewedCredentials.expiresAt, - Is.`is`(result.expiresAt) + result.accessToken, + Is.`is`(renewedCredentials.accessToken) ) MatcherAssert.assertThat( - renewedCredentials.scope, - Is.`is`(result.scope) + result.refreshToken, + Is.`is`(renewedCredentials.refreshToken) ) latch.countDown() } }) } } - latch.await() // Wait for all threads to finish - Mockito.verify(client, Mockito.times(1)) - .renewAuth( - refreshToken = "refreshToken" - ) // verify that api client's renewAuth is called only once - Mockito.verify(request, Mockito.times(1)).execute() // Verify single network request + + Assert.assertTrue(latch.await(2, TimeUnit.SECONDS)) + // Exactly one renewal — the second caller found already-valid credentials in storage. + Mockito.verify(client, Mockito.times(1)).renewAuth(refreshToken = "refreshToken") + Mockito.verify(request, Mockito.times(1)).execute() } /* @@ -2149,10 +2362,7 @@ public class SecureCredentialsManagerTest { @Test public fun shouldClearCredentials() { manager.clearCredentials() - verify(storage).remove("com.auth0.credentials") - verify(storage).remove("com.auth0.credentials_expires_at") - verify(storage).remove("com.auth0.credentials_access_token_expires_at") - verify(storage).remove("com.auth0.credentials_can_refresh") + verify(storage).removeAll() verifyNoMoreInteractions(storage) } @@ -2165,6 +2375,14 @@ public class SecureCredentialsManagerTest { verifyNoMoreInteractions(storage) } + @Test + public fun shouldClearAllCredentialsKeyPairsAndBiometricSession() { + manager.clearAll() + verify(storage).removeAll() + verify(crypto).deleteAllKeys() + Assert.assertFalse(manager.isBiometricSessionValid()) + } + @Test public fun shouldSaveEncryptedApiCredentialsWithScopeAsKey() { val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS @@ -2498,6 +2716,103 @@ public class SecureCredentialsManagerTest { MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true)) } + @Test + public fun shouldRenewCredentialsViaCallbackWhenTokenExpiresWithinDefaultMinTtl() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + 30 * 1000) + insertTestCredentials(false, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveLong("com.auth0.credentials_access_token_expires_at")) + .thenReturn(expiresAt.time) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + Mockito.`when`( + client.renewAuth("refreshToken") + ).thenReturn(request) + val expectedCredentials = + Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(expectedCredentials) + val expectedJson = gson.toJson(expectedCredentials) + Mockito.`when`(crypto.encrypt(expectedJson.toByteArray())) + .thenReturn(expectedJson.toByteArray()) + // Use no-arg getCredentials which now uses DEFAULT_MIN_TTL + manager.getCredentials(callback) + verify(callback).onSuccess( + credentialsCaptor.capture() + ) + // Verify renewal was triggered + verify(client).renewAuth("refreshToken") + val retrievedCredentials = credentialsCaptor.firstValue + MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(retrievedCredentials.idToken, Is.`is`("newId")) + MatcherAssert.assertThat(retrievedCredentials.accessToken, Is.`is`("newAccess")) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitRenewedCredentialsWhenTokenExpiresWithinDefaultMinTtl(): Unit = runTest { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + 30 * 1000) + insertTestCredentials(false, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveLong("com.auth0.credentials_access_token_expires_at")) + .thenReturn(expiresAt.time) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + Mockito.`when`( + client.renewAuth("refreshToken") + ).thenReturn(request) + val expectedCredentials = + Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(expectedCredentials) + val expectedJson = gson.toJson(expectedCredentials) + Mockito.`when`(crypto.encrypt(expectedJson.toByteArray())) + .thenReturn(expectedJson.toByteArray()) + // Use no-arg awaitCredentials which now uses DEFAULT_MIN_TTL + val result = manager.awaitCredentials() + // Verify renewal was triggered + verify(client).renewAuth("refreshToken") + MatcherAssert.assertThat(result, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(result.idToken, Is.`is`("newId")) + MatcherAssert.assertThat(result.accessToken, Is.`is`("newAccess")) + } + + @Test + public fun shouldNotHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlAndNoRefreshToken() { + // Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), and no refresh token + val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000 + Mockito.`when`(storage.retrieveLong("com.auth0.credentials_access_token_expires_at")) + .thenReturn(expirationTime) + Mockito.`when`(storage.retrieveBoolean("com.auth0.credentials_can_refresh")) + .thenReturn(false) + Mockito.`when`(storage.retrieveString("com.auth0.credentials")) + .thenReturn("{\"access_token\":\"accessToken\"}") + // No-arg hasValidCredentials now uses DEFAULT_MIN_TTL, so token expiring in 30s is invalid + Assert.assertFalse(manager.hasValidCredentials()) + } + + @Test + public fun shouldHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlButRefreshTokenAvailable() { + // Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), but refresh token is available + val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000 + Mockito.`when`(storage.retrieveLong("com.auth0.credentials_access_token_expires_at")) + .thenReturn(expirationTime) + Mockito.`when`(storage.retrieveBoolean("com.auth0.credentials_can_refresh")) + .thenReturn(true) + Mockito.`when`(storage.retrieveString("com.auth0.credentials")) + .thenReturn("{\"access_token\":\"accessToken\", \"refresh_token\":\"refreshToken\"}") + // Even though token expires within DEFAULT_MIN_TTL, refresh token makes it valid + MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true)) + } + @Test public fun shouldHaveCredentialsWhenTheAliasUsedHasNotBeenMigratedYet() { val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS @@ -3314,7 +3629,7 @@ public class SecureCredentialsManagerTest { storage, crypto, jwtDecoder, - auth0.executor, + serialExecutor, weakFragmentActivity, getAuthenticationOptions(), factory @@ -3331,7 +3646,7 @@ public class SecureCredentialsManagerTest { //now, update the clock and retry manager.setClock(object : Clock { override fun getCurrentTimeMillis(): Long { - return CredentialsMock.CURRENT_TIME_MS - 1000 + return CredentialsMock.CURRENT_TIME_MS - (DEFAULT_MIN_TTL * 1000 + 1000) } }) MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true)) @@ -3388,13 +3703,12 @@ public class SecureCredentialsManagerTest { throw IllegalArgumentException("Proper Executor Set") } } - Mockito.`when`(auth0.executor).thenReturn(serialExecutor) val manager = SecureCredentialsManager( client, storage, crypto, jwtDecoder, - auth0.executor, + serialExecutor, weakFragmentActivity, getAuthenticationOptions(), factory @@ -3542,25 +3856,220 @@ public class SecureCredentialsManagerTest { MatcherAssert.assertThat(retrievedCredentials.scope, Is.`is`("scope")) } - /* - * Helper methods - */ - /** - * Used to simplify the tests length - */ - private fun insertTestCredentials( - hasIdToken: Boolean, - hasAccessToken: Boolean, - hasRefreshToken: Boolean, - willExpireAt: Date, - scope: String? - ): String { - val storedCredentials = Credentials( - if (hasIdToken) "idToken" else "", - if (hasAccessToken) "accessToken" else "", - "type", - if (hasRefreshToken) "refreshToken" else null, - willExpireAt, + // IPSIE session_expiry enforcement + + @Test + public fun shouldFailWithSessionExpiredWithoutBiometricsOrRenewWhenStoredCeilingReached() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Stored ceiling already in the past. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No biometric prompt should be raised for a dead session. + verify(localAuthenticationManager, never()).authenticate() + // The breached session must be cleared. + verify(storage).removeAll() + // The refresh-token grant must never be used past the ceiling. + verifyNoMoreInteractions(client) + } + + @Test + public fun shouldFailWithSessionExpiredWhenStoredCeilingFallsWithinLeeway() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // 10s ahead, but inside the 30s negative leeway -> treated as expired. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds + 10) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No refresh past the ceiling. + verifyNoMoreInteractions(client) + } + + @Test + public fun shouldGetCredentialsWhenStoredSessionCeilingNotReached() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")) + .thenReturn(nowSeconds + 100_000) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + // No refresh needed (token not expired). + verifyNoMoreInteractions(client) + } + + @Test + public fun shouldNotEnforceSessionExpiryWhenNoStoredCeiling() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // No stored ceiling -> existing behavior, no regression. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + verifyNoMoreInteractions(client) + } + + @Test + public fun shouldThrowWhenSavingCredentialsAlreadyPastSessionCeiling() { + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val jwtMock = mock() + // session_expiry (1000) is at/below iat (2000) -> already expired at creation. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(1000L) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(2000L * 1000)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + val exception = assertThrows(CredentialsManagerException::class.java) { + manager.saveCredentials(credentials) + } + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.SESSION_EXPIRED)) + verify(storage, never()).store(eq("com.auth0.credentials"), anyString()) + } + + @Test + public fun shouldPersistSessionExpiryWhenSavingCredentials() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", Date(expirationTime), "scope" + ) + val sessionExpiry = (expirationTime / 1000) + 100_000 + val json = gson.toJson(credentials) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(expirationTime)) + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + verify(storage).store("com.auth0.session_expiry", sessionExpiry) + } + + @Test + public fun shouldNotOverwriteStoredSessionExpiryWhenSavingRefreshedCredentials() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", Date(expirationTime), "scope" + ) + // A ceiling is already pinned from the initial login. + val pinnedCeiling = (expirationTime / 1000) + 100_000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + val json = gson.toJson(credentials) + // The refreshed ID token re-emits a later session_expiry that must be ignored. + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(expirationTime)) + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(pinnedCeiling + 100_000) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + // The pinned ceiling must not be moved by a refresh-grant claim. + verify(storage, never()).store(eq("com.auth0.session_expiry"), anyLong()) + } + + @Test + public fun shouldFailGetSsoCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + insertTestCredentials( + hasIdToken = true, + hasAccessToken = true, + hasRefreshToken = true, + willExpireAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + scope = "scope" + ) + // Nothing pinned in storage, so the ceiling resolves from the decrypted ID token claim + // below. (Mockito returns 0L for the unstubbed key, which the storage-first lookup in + // isSessionExpired would otherwise consume as a bogus "no ceiling" value.) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) + // The decrypted ID token carries a session_expiry ceiling already in the past. + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(nowSeconds - 100) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for SSO credentials past the ceiling. + verifyNoMoreInteractions(client) + verify(storage).removeAll() + } + + @Test + public fun shouldFailGetApiCredentialsWithSessionExpiredWhenStoredCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Stored ceiling already in the past; the encrypted blob need not be read. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No biometric prompt and no refresh past the ceiling. + verify(localAuthenticationManager, never()).authenticate() + verifyNoMoreInteractions(client) + verify(storage).removeAll() + } + + /* + * Helper methods + */ + /** + * Used to simplify the tests length + */ + private fun insertTestCredentials( + hasIdToken: Boolean, + hasAccessToken: Boolean, + hasRefreshToken: Boolean, + willExpireAt: Date, + scope: String? + ): String { + val storedCredentials = Credentials( + if (hasIdToken) "idToken" else "", + if (hasAccessToken) "accessToken" else "", + "type", + if (hasRefreshToken) "refreshToken" else null, + willExpireAt, scope ) val storedJson = gson.toJson(storedCredentials) @@ -3598,9 +4107,382 @@ public class SecureCredentialsManagerTest { } + @Test + public fun shouldStoreDPoPThumbprintWhenCredentialsTypeIsDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldStoreDPoPThumbprintWhenIsDPoPEnabledIsTrue() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldRemoveDPoPThumbprintForBearerCredentialsWithoutDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + } + + @Test + public fun shouldRemoveDPoPThumbprintWhenNoKeyPairExists() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + } + + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPNotConfiguredWhenClientNotSetup() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat( + exception, + Is.`is`(CredentialsManagerException.DPOP_NOT_CONFIGURED) + ) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMismatchWhenThumbprintsDontMatch() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("old-thumbprint-from-previous-key") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISMATCH)) + } + + @Test + public fun shouldBackfillDPoPThumbprintForMigrationScenario() { + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + // No stored thumbprint (migration scenario) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")).thenReturn(null) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + val renewedCredentials = + Credentials("newId", "newAccess", "DPoP", "newRefresh", newDate, "scope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())).thenReturn(expectedJson.toByteArray()) + + manager.continueGetCredentials(null, 0, emptyMap(), emptyMap(), false, callback) + + // Verify thumbprint was backfilled during validation (and also stored again during saveCredentials after renewal) + verify(storage, Mockito.atLeastOnce()).store( + eq("com.auth0.dpop_key_thumbprint"), + anyString() + ) + verify(callback).onSuccess(credentialsCaptor.capture()) + } + + @Test + public fun shouldTriggerDPoPValidationViaStoredThumbprintEvenForBearerTokenType() { + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + // Thumbprint exists (DPoP was used, but token_type is Bearer — RFC 9449 edge case) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("some-thumbprint") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetCredentials(null, 0, emptyMap(), emptyMap(), false, callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetSsoCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetApiCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + val storedJson = gson.toJson(apiCredentials) + val encoded = String(Base64.encode(storedJson.toByteArray(), Base64.DEFAULT)) + Mockito.`when`(crypto.decrypt(storedJson.toByteArray())) + .thenReturn(storedJson.toByteArray()) + Mockito.`when`(storage.retrieveString("audience::read:data")).thenReturn(encoded) + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetApiCredentials( + "audience", + "read:data", + 0, + emptyMap(), + emptyMap(), + apiCredentialsCallback + ) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldUseApiCredentialTypeForDPoPValidationInsteadOfBaseTokenType() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + val storedJson = gson.toJson(apiCredentials) + val encoded = String(Base64.encode(storedJson.toByteArray(), Base64.DEFAULT)) + Mockito.`when`(crypto.decrypt(storedJson.toByteArray())) + .thenReturn(storedJson.toByteArray()) + Mockito.`when`(storage.retrieveString("audience::read:data")).thenReturn(encoded) + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetApiCredentials( + "audience", + "read:data", + 0, + emptyMap(), + emptyMap(), + apiCredentialsCallback + ) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + // Verifies the outer runCatchingOnExecutor safety net in continueGetCredentials. + // The IllegalStateException is thrown from storage.retrieveString() which is outside any inner + // try/catch block — only the outer catch(Throwable) in runCatchingOnExecutor can handle it. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.credentials")).thenThrow(error) + manager.continueGetCredentials(null, 0, emptyMap(), emptyMap(), false, callback) + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net in getSsoCredentials. + // The exception is thrown from getExistingCredentials() → storage.retrieveString() + // before any inner try/catch is reached. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetSsoCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.credentials")).thenThrow(error) + manager.getSsoCredentials(ssoCallback) + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net in continueGetApiCredentials. + // The exception is thrown from storage before any inner try/catch is reached. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetApiCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("audience")).thenThrow(error) + manager.continueGetApiCredentials("audience", null, 0, emptyMap(), emptyMap(), apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net during API credential renewal. + // NullPointerException from request.execute() is not an AuthenticationException, so it + // escapes the inner catch(AuthenticationException) block. Only the outer catch(Throwable) + // in runCatchingOnExecutor handles it. + @Test + public fun shouldCatchUnexpectedExceptionDuringApiCredentialRenewal() { + val error = NullPointerException("unexpected null") + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + insertTestCredentials( + hasIdToken = true, + hasAccessToken = true, + hasRefreshToken = true, + willExpireAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + scope = "scope" + ) + Mockito.`when`(client.renewAuth("refreshToken", "audience", null)).thenReturn(request) + Mockito.`when`(request.execute()).thenThrow(error) + manager.continueGetApiCredentials("audience", null, 0, emptyMap(), emptyMap(), apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + @Test + public fun shouldFailWithStoreFailedWhenSavingRenewedApiCredentialsThrows() { + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials( + hasIdToken = true, + hasAccessToken = true, + hasRefreshToken = true, + willExpireAt = expiresAt, + scope = "scope" + ) + Mockito.`when`( + client.renewAuth("refreshToken", "audience", "newScope") + ).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + + // Renewal succeeds but persisting the credentials fails + val renewedCredentials = + Credentials("newId", "newAccess", "newType", null, newDate, "newScope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())) + .thenThrow(CryptoException("CryptoException is thrown")) + + manager.continueGetApiCredentials( + "audience", "newScope", 0, emptyMap(), emptyMap(), apiCredentialsCallback + ) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat( + exception.message, + Is.`is`("An error occurred while saving the refreshed Credentials.") + ) + Mockito.verify(apiCredentialsCallback, Mockito.never()).onSuccess(any()) + } + + @After + public fun tearDown() { + DPoPUtil.keyStore = DPoPKeyStore() + } + private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) + // Default to a token without the IPSIE session_expiry claim. Mockito returns 0 (not null) for + // an unstubbed Long?-returning property, which would otherwise trigger a spurious persist. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(null) Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SharedPreferencesStorageTest.java b/auth0/src/test/java/com/auth0/android/authentication/storage/SharedPreferencesStorageTest.java index 83b63210c..89c2c7983 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SharedPreferencesStorageTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SharedPreferencesStorageTest.java @@ -16,12 +16,12 @@ import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsNull.nullValue; import static org.mockito.ArgumentMatchers.anySet; -import static org.mockito.Matchers.anyBoolean; -import static org.mockito.Matchers.anyFloat; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Matchers.anyLong; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -221,4 +221,13 @@ public void shouldRemovePreferencesKey() { verify(sharedPreferencesEditor).apply(); } + @Test + public void shouldRemoveAllPreferencesKeys() { + when(sharedPreferencesEditor.clear()).thenReturn(sharedPreferencesEditor); + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + storage.removeAll(); + verify(sharedPreferencesEditor).clear(); + verify(sharedPreferencesEditor).apply(); + } + } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt index c8a8c6a4c..3a445bc2d 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt @@ -5,38 +5,39 @@ import android.content.pm.PackageManager import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import android.util.Log -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.anyOrNull -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.notNullValue import org.hamcrest.Matchers.nullValue +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito import org.mockito.Mockito.doNothing import org.mockito.Mockito.times import org.mockito.Mockito.`when` -import org.powermock.api.mockito.PowerMockito -import org.powermock.core.classloader.annotations.PrepareForTest -import org.powermock.modules.junit4.PowerMockRunner -import org.powermock.reflect.Whitebox +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import java.security.InvalidAlgorithmParameterException import java.security.KeyPairGenerator import java.security.KeyStore import java.security.KeyStoreException +import java.security.NoSuchAlgorithmException import java.security.PrivateKey import java.security.ProviderException import java.security.PublicKey +import java.security.UnrecoverableKeyException import java.security.cert.Certificate -import javax.security.auth.x500.X500Principal /** * Using a subclass of [DPoPKeyStore] to help with mocking the lazy initialized keyStore property @@ -45,26 +46,20 @@ internal class MockableDPoPKeyStore(private val mockKeyStore: KeyStore) : DPoPKe override val keyStore: KeyStore by lazy { mockKeyStore } } -@RunWith(PowerMockRunner::class) -@PrepareForTest( - DPoPKeyStore::class, - KeyStore::class, - KeyPairGenerator::class, - KeyGenParameterSpec.Builder::class, - Build.VERSION::class, - X500Principal::class, - Log::class -) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) public class DPoPKeyStoreTest { private lateinit var mockKeyStore: KeyStore private lateinit var mockKeyPairGenerator: KeyPairGenerator private lateinit var mockContext: Context private lateinit var mockPackageManager: PackageManager - private lateinit var mockSpecBuilder: KeyGenParameterSpec.Builder private lateinit var dpopKeyStore: DPoPKeyStore + private lateinit var keyStoreMock: MockedStatic + private lateinit var keyPairGeneratorMock: MockedStatic + @Before public fun setUp() { @@ -72,32 +67,20 @@ public class DPoPKeyStoreTest { mockKeyPairGenerator = mock() mockContext = mock() mockPackageManager = mock() - mockSpecBuilder = mock() - - PowerMockito.mockStatic(KeyStore::class.java) - PowerMockito.mockStatic(KeyPairGenerator::class.java) - PowerMockito.mockStatic(Log::class.java) - PowerMockito.mockStatic(Build.VERSION::class.java) - Whitebox.setInternalState(Build.VERSION::class.java, "SDK_INT", Build.VERSION_CODES.P) - PowerMockito.whenNew(KeyGenParameterSpec.Builder::class.java).withAnyArguments() - .thenReturn(mockSpecBuilder) + keyStoreMock = Mockito.mockStatic(KeyStore::class.java) + keyPairGeneratorMock = Mockito.mockStatic(KeyPairGenerator::class.java) - PowerMockito.`when`(KeyStore.getInstance("AndroidKeyStore")).thenReturn(mockKeyStore) + keyStoreMock.`when` { KeyStore.getInstance("AndroidKeyStore") } + .thenReturn(mockKeyStore) doNothing().whenever(mockKeyStore).load(anyOrNull()) - PowerMockito.`when`( + keyPairGeneratorMock.`when` { KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore" ) - ).thenReturn(mockKeyPairGenerator) - - whenever(mockSpecBuilder.setAlgorithmParameterSpec(any())).thenReturn(mockSpecBuilder) - whenever(mockSpecBuilder.setDigests(any())).thenReturn(mockSpecBuilder) - whenever(mockSpecBuilder.setCertificateSubject(any())).thenReturn(mockSpecBuilder) - whenever(mockSpecBuilder.setCertificateNotBefore(any())).thenReturn(mockSpecBuilder) - whenever(mockSpecBuilder.setCertificateNotAfter(any())).thenReturn(mockSpecBuilder) - whenever(mockSpecBuilder.setIsStrongBoxBacked(any())).thenReturn(mockSpecBuilder) + }.thenReturn(mockKeyPairGenerator) + whenever(mockContext.packageManager).thenReturn(mockPackageManager) whenever(mockPackageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)).thenReturn( true @@ -106,6 +89,12 @@ public class DPoPKeyStoreTest { dpopKeyStore = MockableDPoPKeyStore(mockKeyStore) } + @After + public fun tearDown() { + keyStoreMock.close() + keyPairGeneratorMock.close() + } + @Test public fun `generateKeyPair should generate a key pair successfully`() { whenever(mockPackageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)).thenReturn( @@ -113,23 +102,26 @@ public class DPoPKeyStoreTest { ) dpopKeyStore.generateKeyPair(mockContext) - verify(mockKeyPairGenerator).initialize(mockSpecBuilder.build()) + verify(mockKeyPairGenerator).initialize(anyOrNull()) verify(mockKeyPairGenerator).generateKeyPair() - verify(mockSpecBuilder, never()).setIsStrongBoxBacked(true) } @Test public fun `generateKeyPair should enable StrongBox when available`() { + val specCaptor = argumentCaptor() + dpopKeyStore.generateKeyPair(mockContext) - verify(mockSpecBuilder).setIsStrongBoxBacked(true) + + verify(mockKeyPairGenerator).initialize(specCaptor.capture()) + verify(mockKeyPairGenerator).generateKeyPair() + + assertThat(specCaptor.firstValue.isStrongBoxBacked, `is`(true)) } @Test public fun `generateKeyPair should throw KEY_GENERATION_ERROR when failed to generate key pair`() { val cause = InvalidAlgorithmParameterException("Exception") - PowerMockito.`when`( - mockKeyPairGenerator.initialize(mockSpecBuilder.build()) - ).thenThrow(cause) + `when`(mockKeyPairGenerator.initialize(anyOrNull())).thenThrow(cause) val exception = assertThrows(DPoPException::class.java) { dpopKeyStore.generateKeyPair(mockContext) @@ -141,9 +133,7 @@ public class DPoPKeyStoreTest { @Test public fun `generateKeyPair should throw UNKNOWN_ERROR when any unhandled exception occurs`() { val cause = RuntimeException("Exception") - PowerMockito.`when`( - mockKeyPairGenerator.initialize(mockSpecBuilder.build()) - ).thenThrow(cause) + `when`(mockKeyPairGenerator.initialize(anyOrNull())).thenThrow(cause) val exception = assertThrows(DPoPException::class.java) { dpopKeyStore.generateKeyPair(mockContext) @@ -191,6 +181,54 @@ public class DPoPKeyStoreTest { assertThat(exception.cause, `is`(cause)) } + @Test + public fun `getKeyPair should throw KEY_STORE_ERROR on NoSuchAlgorithmException`() { + val cause = NoSuchAlgorithmException("Test Exception") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `getKeyPair should throw KEY_STORE_ERROR on UnrecoverableKeyException`() { + val cause = UnrecoverableKeyException("Test Exception") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `getKeyPair should throw KEY_STORE_ERROR on ClassCastException`() { + val cause = ClassCastException("Test Exception") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `getKeyPair should throw UNKNOWN_ERROR on unhandled exception`() { + val cause = RuntimeException("Unexpected error") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.UNKNOWN_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + @Test public fun `hasKeyPair should return true when alias exists`() { whenever(mockKeyStore.containsAlias(any())).thenReturn(true) @@ -217,6 +255,18 @@ public class DPoPKeyStoreTest { assertThat(exception.cause, `is`(cause)) } + @Test + public fun `hasKeyPair should throw UNKNOWN_ERROR on unhandled exception`() { + val cause = RuntimeException("Unexpected error") + whenever(mockKeyStore.containsAlias(any())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.hasKeyPair() + } + assertEquals(exception.message, DPoPException.UNKNOWN_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + @Test public fun `deleteKeyPair should call deleteEntry`() { dpopKeyStore.deleteKeyPair() @@ -235,29 +285,42 @@ public class DPoPKeyStoreTest { assertThat(exception.cause, `is`(cause)) } + + @Test + public fun `deleteKeyPair should throw UNKNOWN_ERROR on unhandled exception`() { + val cause = RuntimeException("Unexpected error") + whenever(mockKeyStore.deleteEntry(any())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.deleteKeyPair() + } + assertEquals(exception.message, DPoPException.UNKNOWN_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + @Test public fun `generateKeyPair should retry without StrongBox when ProviderException occurs with StrongBox enabled`() { val providerException = ProviderException("StrongBox attestation failed") + val specCaptor = argumentCaptor() `when`(mockKeyPairGenerator.generateKeyPair()).thenThrow(providerException) .thenReturn(mock()) dpopKeyStore.generateKeyPair(mockContext) - verify(mockKeyPairGenerator, times(2)).initialize(mockSpecBuilder.build()) + verify(mockKeyPairGenerator, times(2)).initialize(specCaptor.capture()) verify(mockKeyPairGenerator, times(2)).generateKeyPair() - verify(mockSpecBuilder).setIsStrongBoxBacked(true) // First attempt - verify( - mockSpecBuilder, - never() - ).setIsStrongBoxBacked(false) + assertThat(specCaptor.allValues[0].isStrongBoxBacked, `is`(true)) + assertThat(specCaptor.allValues[1].isStrongBoxBacked, `is`(false)) } @Test public fun `generateKeyPair should throw KEY_GENERATION_ERROR when ProviderException occurs without StrongBox`() { val providerException = ProviderException("Key generation failed") - `when`(mockKeyPairGenerator.initialize(mockSpecBuilder.build())).thenThrow(providerException) + `when`(mockKeyPairGenerator.initialize(anyOrNull())).thenThrow( + providerException + ) val exception = assertThrows(DPoPException::class.java) { dpopKeyStore.generateKeyPair(mockContext, useStrongBox = false) @@ -266,7 +329,7 @@ public class DPoPKeyStoreTest { assertEquals(DPoPException.KEY_GENERATION_ERROR.message, exception.message) assertThat(exception.cause, `is`(providerException)) - verify(mockKeyPairGenerator, times(1)).initialize(mockSpecBuilder.build()) + verify(mockKeyPairGenerator, times(1)).initialize(anyOrNull()) } @Test @@ -274,7 +337,7 @@ public class DPoPKeyStoreTest { val firstException = ProviderException("StrongBox failed") val secondException = ProviderException("Retry also failed") - `when`(mockKeyPairGenerator.initialize(mockSpecBuilder.build())) + `when`(mockKeyPairGenerator.initialize(anyOrNull())) .thenThrow(firstException) .thenThrow(secondException) @@ -285,6 +348,6 @@ public class DPoPKeyStoreTest { assertEquals(DPoPException.KEY_GENERATION_ERROR.message, exception.message) assertThat(exception.cause, `is`(secondException)) - verify(mockKeyPairGenerator, times(2)).initialize(mockSpecBuilder.build()) + verify(mockKeyPairGenerator, times(2)).initialize(anyOrNull()) } } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt index 3518e70d5..f2c45c365 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPTest.kt @@ -3,9 +3,9 @@ package com.auth0.android.dpop import android.content.Context import com.auth0.android.request.HttpMethod import com.auth0.android.request.internal.Jwt -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import okhttp3.Headers import okhttp3.Response import okhttp3.ResponseBody diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt index 1ba8050a9..19287e814 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPUtilTest.kt @@ -3,12 +3,15 @@ package com.auth0.android.dpop import android.content.Context import com.auth0.android.request.internal.Jwt import com.google.gson.internal.LinkedTreeMap -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions -import com.nhaarman.mockitokotlin2.whenever +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever import okhttp3.Response import org.hamcrest.CoreMatchers.`is` import org.hamcrest.CoreMatchers.notNullValue diff --git a/auth0/src/test/java/com/auth0/android/management/ManagementExceptionTest.kt b/auth0/src/test/java/com/auth0/android/management/ManagementExceptionTest.kt deleted file mode 100644 index 9a97d1323..000000000 --- a/auth0/src/test/java/com/auth0/android/management/ManagementExceptionTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.auth0.android.management - -import com.auth0.android.NetworkErrorException -import org.hamcrest.CoreMatchers -import org.hamcrest.MatcherAssert -import org.junit.Test -import java.io.IOException - -public class ManagementExceptionTest { - @Test - public fun shouldNotHaveNetworkError() { - val ex = ManagementException("Something else happened") - MatcherAssert.assertThat(ex.isNetworkError, CoreMatchers.`is`(false)) - } - - @Test - public fun shouldHaveNetworkError() { - val ex = ManagementException( - "Request has definitely failed", NetworkErrorException( - IOException() - ) - ) - MatcherAssert.assertThat(ex.isNetworkError, CoreMatchers.`is`(true)) - } -} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/management/UsersAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/management/UsersAPIClientTest.kt deleted file mode 100755 index 728e026d0..000000000 --- a/auth0/src/test/java/com/auth0/android/management/UsersAPIClientTest.kt +++ /dev/null @@ -1,466 +0,0 @@ -package com.auth0.android.management - -import android.content.Context -import android.content.res.Resources -import com.auth0.android.Auth0 -import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.authentication.AuthenticationAPIClientTest -import com.auth0.android.request.HttpMethod.GET -import com.auth0.android.request.NetworkingClient -import com.auth0.android.request.RequestOptions -import com.auth0.android.request.ServerResponse -import com.auth0.android.request.internal.RequestFactory -import com.auth0.android.request.internal.ThreadSwitcherShadow -import com.auth0.android.result.UserIdentity -import com.auth0.android.result.UserProfile -import com.auth0.android.util.* -import com.auth0.android.util.SSLTestUtils.testClient -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.reflect.TypeToken -import com.nhaarman.mockitokotlin2.* -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.RecordedRequest -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers -import org.hamcrest.Matchers.instanceOf -import org.hamcrest.Matchers.notNullValue -import org.hamcrest.collection.IsMapContaining -import org.hamcrest.collection.IsMapWithSize -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.shadows.ShadowLooper -import java.io.ByteArrayInputStream -import java.io.InputStream -import java.util.* - - -@RunWith(RobolectricTestRunner::class) -@Config(shadows = [ThreadSwitcherShadow::class]) -public class UsersAPIClientTest { - private lateinit var client: UsersAPIClient - private lateinit var gson: Gson - private lateinit var mockAPI: UsersAPIMockServer - - @Before - public fun setUp() { - mockAPI = UsersAPIMockServer() - val domain = mockAPI.domain - val auth0 = Auth0.getInstance(CLIENT_ID, domain, domain) - auth0.networkingClient = testClient - client = UsersAPIClient(auth0, TOKEN_PRIMARY) - gson = GsonBuilder().serializeNulls().create() - } - - @After - public fun tearDown() { - mockAPI.shutdown() - } - - @Test - public fun shouldUseCustomNetworkingClient() { - val account = Auth0.getInstance("client-id", "https://tenant.auth0.com/") - val jsonResponse = """{"id": "undercover"}""" - val inputStream: InputStream = ByteArrayInputStream(jsonResponse.toByteArray()) - val response = ServerResponse(200, inputStream, emptyMap()) - val networkingClient: NetworkingClient = mock() - - whenever(networkingClient.load(any(), any())).thenReturn(response) - account.networkingClient = networkingClient - val client = UsersAPIClient(account, "token.token") - val request = client.getProfile("undercover") - - request.execute() - ShadowLooper.idleMainLooper() - argumentCaptor().apply { - verify(networkingClient).load(eq("https://tenant.auth0.com/api/v2/users/undercover"), capture()) - assertThat(firstValue, Matchers.`is`(notNullValue())) - assertThat(firstValue.method, Matchers.`is`(instanceOf(GET::class.java))) - assertThat(firstValue.parameters, IsMapWithSize.anEmptyMap()) - assertThat(firstValue.headers, IsMapContaining.hasKey("Auth0-Client")) - } - } - - @Test - public fun shouldSetAuth0UserAgentIfPresent() { - val auth0UserAgent: Auth0UserAgent = mock() - val factory: RequestFactory = mock() - val account = Auth0.getInstance(CLIENT_ID, DOMAIN) - - whenever(auth0UserAgent.value).thenReturn("the-user-agent-data") - account.auth0UserAgent = auth0UserAgent - UsersAPIClient(account, factory, gson) - - verify(factory).setAuth0ClientInfo("the-user-agent-data") - } - - @Test - public fun shouldCreateClientWithAccountInfo() { - val client = UsersAPIClient(Auth0.getInstance(CLIENT_ID, DOMAIN), TOKEN_PRIMARY) - assertThat(client, Matchers.`is`(notNullValue())) - assertThat(client.clientId, Matchers.equalTo(CLIENT_ID)) - assertThat(client.baseURL, Matchers.equalTo("https://$DOMAIN/")) - } - - @Test - public fun shouldCreateClientWithContextInfo() { - val context: Context = mock() - val resources: Resources = mock() - - whenever(context.packageName).thenReturn("com.myapp") - whenever(context.resources).thenReturn(resources) - whenever(resources.getIdentifier( - eq("com_auth0_client_id"), - eq("string"), - eq("com.myapp") - )).thenReturn(222) - whenever(resources.getIdentifier( - eq("com_auth0_domain"), - eq("string"), - eq("com.myapp") - )).thenReturn(333) - whenever(context.getString(eq(222))).thenReturn(CLIENT_ID) - whenever(context.getString(eq(333))).thenReturn(DOMAIN) - - val client = UsersAPIClient(Auth0.getInstance(context), TOKEN_PRIMARY) - assertThat(client, Matchers.`is`(notNullValue())) - assertThat(client.clientId, Matchers.`is`(CLIENT_ID)) - assertThat(client.baseURL, Matchers.equalTo("https://$DOMAIN/")) - } - - @Test - public fun shouldLinkAccount() { - mockAPI.willReturnSuccessfulLink() - val callback = MockManagementCallback>() - client.link(USER_ID_PRIMARY, TOKEN_SECONDARY) - .start(callback) - ShadowLooper.idleMainLooper() - - val request = mockAPI.takeRequest() - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY/identities") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_POST)) - - val body = bodyFromRequest(request) - assertThat(body, Matchers.hasEntry(KEY_LINK_WITH, TOKEN_SECONDARY)) - - val typeToken: TypeToken> = object : TypeToken>() {} - assertThat(callback, ManagementCallbackMatcher.hasPayloadOfType(typeToken)) - assertThat(callback.payload.size, Matchers.`is`(2)) - } - - @Test - public fun shouldLinkAccountSync() { - mockAPI.willReturnSuccessfulLink() - val result = client.link(USER_ID_PRIMARY, TOKEN_SECONDARY) - .execute() - val request = mockAPI.takeRequest() - - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY/identities") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_POST)) - - val body = bodyFromRequest(request) - assertThat(body, Matchers.hasEntry(KEY_LINK_WITH, TOKEN_SECONDARY)) - - val typeToken: TypeToken> = object : TypeToken>() {} - assertThat(result, TypeTokenMatcher.isA(typeToken)) - assertThat(result.size, Matchers.`is`(2)) - } - - @Test - @ExperimentalCoroutinesApi - public fun shouldAwaitLinkAccount(): Unit = runTest { - mockAPI.willReturnSuccessfulLink() - val result = client.link(USER_ID_PRIMARY, TOKEN_SECONDARY) - .await() - val request = mockAPI.takeRequest() - - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY/identities") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_POST)) - - val body = bodyFromRequest(request) - assertThat(body, Matchers.hasEntry(KEY_LINK_WITH, TOKEN_SECONDARY)) - - val typeToken: TypeToken> = object : TypeToken>() {} - assertThat(result, TypeTokenMatcher.isA(typeToken)) - assertThat(result.size, Matchers.`is`(2)) - } - - - @Test - public fun shouldUnlinkAccount() { - mockAPI.willReturnSuccessfulUnlink() - val callback = MockManagementCallback>() - client.unlink(USER_ID_PRIMARY, USER_ID_SECONDARY, PROVIDER) - .start(callback) - ShadowLooper.idleMainLooper() - - val request = mockAPI.takeRequest() - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY/identities/$PROVIDER/$USER_ID_SECONDARY") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_DELETE)) - - val body = bodyFromRequest(request) - assertThat(body, IsMapWithSize.anEmptyMap()) - - val typeToken: TypeToken> = object : TypeToken>() {} - assertThat(callback, ManagementCallbackMatcher.hasPayloadOfType(typeToken)) - assertThat(callback.payload.size, Matchers.`is`(1)) - } - - @Test - public fun shouldUnlinkAccountSync() { - mockAPI.willReturnSuccessfulUnlink() - val result = client.unlink(USER_ID_PRIMARY, USER_ID_SECONDARY, PROVIDER) - .execute() - - val request = mockAPI.takeRequest() - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY/identities/$PROVIDER/$USER_ID_SECONDARY") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_DELETE)) - - val body = bodyFromRequest(request) - assertThat(body, IsMapWithSize.anEmptyMap()) - - val typeToken: TypeToken> = object : TypeToken>() {} - assertThat(result, TypeTokenMatcher.isA(typeToken)) - assertThat(result.size, Matchers.`is`(1)) - } - - @Test - @ExperimentalCoroutinesApi - public fun shouldAwaitUnlinkAccount(): Unit = runTest { - mockAPI.willReturnSuccessfulUnlink() - val result = client.unlink(USER_ID_PRIMARY, USER_ID_SECONDARY, PROVIDER) - .await() - - val request = mockAPI.takeRequest() - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY/identities/$PROVIDER/$USER_ID_SECONDARY") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_DELETE)) - - val body = bodyFromRequest(request) - assertThat(body, IsMapWithSize.anEmptyMap()) - - val typeToken: TypeToken> = object : TypeToken>() {} - assertThat(result, TypeTokenMatcher.isA(typeToken)) - assertThat(result.size, Matchers.`is`(1)) - } - - @Test - public fun shouldUpdateUserMetadata() { - mockAPI.willReturnUserProfile() - val metadata: MutableMap = HashMap() - metadata["boolValue"] = true - metadata["name"] = "my_name" - metadata["list"] = listOf("my", "name", "is") - val callback = MockManagementCallback() - client.updateMetadata(USER_ID_PRIMARY, metadata) - .start(callback) - ShadowLooper.idleMainLooper() - - val request = mockAPI.takeRequest() - assertThat(request.path, Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY")) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_PATCH)) - - val body = bodyFromRequest(request) - assertThat(body, Matchers.hasKey(KEY_USER_METADATA)) - assertThat(body[KEY_USER_METADATA], Matchers.`is`(Matchers.equalTo(metadata))) - assertThat( - callback, - ManagementCallbackMatcher.hasPayloadOfType(UserProfile::class.java) - ) - } - - @Test - public fun shouldUpdateUserMetadataSync() { - mockAPI.willReturnUserProfile() - val metadata: MutableMap = HashMap() - metadata["boolValue"] = true - metadata["name"] = "my_name" - metadata["list"] = listOf("my", "name", "is") - - val result = client.updateMetadata(USER_ID_PRIMARY, metadata) - .execute() - val request = mockAPI.takeRequest() - - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_PATCH)) - - val body = bodyFromRequest(request) - assertThat(body, Matchers.hasKey(KEY_USER_METADATA)) - assertThat(body[KEY_USER_METADATA], Matchers.`is`(Matchers.equalTo(metadata))) - assertThat(result, Matchers.isA(UserProfile::class.java)) - } - - @Test - @ExperimentalCoroutinesApi - public fun shouldAwaitUpdateUserMetadata(): Unit = runTest { - mockAPI.willReturnUserProfile() - val metadata: MutableMap = HashMap() - metadata["boolValue"] = true - metadata["name"] = "my_name" - metadata["list"] = listOf("my", "name", "is") - - val result = client.updateMetadata(USER_ID_PRIMARY, metadata) - .await() - val request = mockAPI.takeRequest() - - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat(request.method, Matchers.equalTo(METHOD_PATCH)) - - val body = bodyFromRequest(request) - assertThat(body, Matchers.hasKey(KEY_USER_METADATA)) - assertThat(body[KEY_USER_METADATA], Matchers.`is`(Matchers.equalTo(metadata))) - assertThat(result, Matchers.isA(UserProfile::class.java)) - } - - @Test - public fun shouldGetUserProfileSync() { - mockAPI.willReturnUserProfile() - val result = client.getProfile(USER_ID_PRIMARY) - .execute() - val request = mockAPI.takeRequest() - - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat( - request.method, - Matchers.equalTo(METHOD_GET) - ) - assertThat(result, Matchers.isA(UserProfile::class.java)) - } - - @Test - @ExperimentalCoroutinesApi - public fun shouldAwaitGetUserProfile(): Unit = runTest { - mockAPI.willReturnUserProfile() - val result = client.getProfile(USER_ID_PRIMARY) - .await() - val request = mockAPI.takeRequest() - - assertThat( - request.path, - Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY") - ) - assertThat( - request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY) - ) - assertThat( - request.method, - Matchers.equalTo(METHOD_GET) - ) - assertThat(result, Matchers.isA(UserProfile::class.java)) - } - - @Test - public fun shouldGetUserProfile() { - mockAPI.willReturnUserProfile() - val callback = MockManagementCallback() - client.getProfile(USER_ID_PRIMARY) - .start(callback) - ShadowLooper.idleMainLooper() - - val request = mockAPI.takeRequest() - assertThat(request.path, Matchers.equalTo("/api/v2/users/$USER_ID_PRIMARY")) - assertThat(request.getHeader(HEADER_AUTHORIZATION), - Matchers.equalTo(BEARER + TOKEN_PRIMARY)) - assertThat(request.method, Matchers.equalTo(METHOD_GET)) - assertThat( - callback, ManagementCallbackMatcher.hasPayloadOfType( - UserProfile::class.java - ) - ) - } - - private fun bodyFromRequest(request: RecordedRequest): Map { - val mapType = object : TypeToken?>() {}.type - return gson.fromJson(request.body.readUtf8(), mapType) - } - - private companion object { - private const val CLIENT_ID = "CLIENTID" - private const val DOMAIN = "samples.auth0.com" - private const val USER_ID_PRIMARY = "primaryUserId" - private const val USER_ID_SECONDARY = "secondaryUserId" - private const val TOKEN_PRIMARY = "primaryToken" - private const val TOKEN_SECONDARY = "secondaryToken" - private const val PROVIDER = "provider" - private const val HEADER_AUTHORIZATION = "Authorization" - private const val BEARER = "Bearer " - private const val METHOD_POST = "POST" - private const val METHOD_DELETE = "DELETE" - private const val METHOD_PATCH = "PATCH" - private const val METHOD_GET = "GET" - private const val KEY_LINK_WITH = "link_with" - private const val KEY_USER_METADATA = "user_metadata" - } -} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index 342cf3b34..d81a86266 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -1,6 +1,11 @@ package com.auth0.android.myaccount +import android.content.Context import com.auth0.android.Auth0 +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.PublicKeyCredentials import com.auth0.android.request.Response import com.auth0.android.result.AuthenticationMethod @@ -8,6 +13,8 @@ import com.auth0.android.result.EnrollmentChallenge import com.auth0.android.result.Factor import com.auth0.android.result.PasskeyAuthenticationMethod import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.android.result.PasswordAuthenticationMethod +import com.auth0.android.result.PasswordEnrollmentChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge import com.auth0.android.util.AuthenticationAPIMockServer.Companion.SESSION_ID @@ -17,7 +24,8 @@ import com.auth0.android.util.SSLTestUtils.testClient import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken -import com.nhaarman.mockitokotlin2.mock +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers @@ -36,11 +44,17 @@ public class MyAccountAPIClientTest { private lateinit var client: MyAccountAPIClient private lateinit var gson: Gson private lateinit var mockAPI: MyAccountAPIMockServer + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context @Before public fun setUp() { mockAPI = MyAccountAPIMockServer() MockitoAnnotations.openMocks(this) + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore gson = GsonBuilder().serializeNulls().create() client = MyAccountAPIClient(auth0, ACCESS_TOKEN) } @@ -50,6 +64,14 @@ public class MyAccountAPIClientTest { mockAPI.shutdown() } + @Test + public fun `should sent user-agent header with request`() { + val callback = MockMyAccountCallback>() + client.getFactors().start(callback) + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Auth0-Client"), Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun `passkeyEnrollmentChallenge should build correct URL`() { val callback = MockMyAccountCallback() @@ -113,7 +135,10 @@ public class MyAccountAPIClientTest { } mockAPI.takeRequest() assertThat(error, Matchers.notNullValue()) - assertThat(error?.message, Matchers.`is`("Authentication method ID not found in Location header.")) + assertThat( + error?.message, + Matchers.`is`("Authentication method ID not found in Location header.") + ) } @@ -334,6 +359,53 @@ public class MyAccountAPIClientTest { assertThat(request.method, Matchers.equalTo("GET")) } + @Test + public fun `getAuthenticationMethods should include type query parameter when specified`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(AuthenticationMethodType.PASSKEY).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods?type=passkey")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should not include type query parameter when null`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(null).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should include correct type value for each AuthenticationMethodType`() { + val typesToExpected = mapOf( + AuthenticationMethodType.PHONE to "phone", + AuthenticationMethodType.EMAIL to "email", + AuthenticationMethodType.TOTP to "totp", + AuthenticationMethodType.PUSH to "push-notification", + AuthenticationMethodType.RECOVERY_CODE to "recovery-code", + AuthenticationMethodType.PASSWORD to "password", + AuthenticationMethodType.WEBAUTHN_PLATFORM to "webauthn-platform", + AuthenticationMethodType.WEBAUTHN_ROAMING to "webauthn-roaming" + ) + + for ((type, expected) in typesToExpected) { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(type).start(callback) + + val request = mockAPI.takeRequest() + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods?type=$expected") + ) + assertThat(request.method, Matchers.equalTo("GET")) + } + } + @Test public fun `getAuthenticationMethodById should build correct URL and Authorization header`() { val callback = MockMyAccountCallback() @@ -362,7 +434,10 @@ public class MyAccountAPIClientTest { public fun `updateAuthenticationMethodById for phone should build correct URL and payload`() { val callback = MockMyAccountCallback() val methodId = "phone|12345" - client.updateAuthenticationMethodById(methodId, preferredAuthenticationMethod = PhoneAuthenticationMethodType.SMS).start(callback) + client.updateAuthenticationMethodById( + methodId, + preferredAuthenticationMethod = PhoneAuthenticationMethodType.SMS + ).start(callback) val request = mockAPI.takeRequest() val body = bodyFromRequest(request) @@ -376,7 +451,8 @@ public class MyAccountAPIClientTest { val callback = MockMyAccountCallback() val methodId = "totp|12345" val name = "My Authenticator" - client.updateAuthenticationMethodById(methodId, authenticationMethodName = name).start(callback) + client.updateAuthenticationMethodById(methodId, authenticationMethodName = name) + .start(callback) val request = mockAPI.takeRequest() val body = bodyFromRequest(request) @@ -449,7 +525,10 @@ public class MyAccountAPIClientTest { val request = mockAPI.takeRequest() val body = bodyFromRequest(request) - assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods/email%7C123/verify")) + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods/email%7C123/verify") + ) assertThat(request.method, Matchers.equalTo("POST")) assertThat(body, Matchers.hasEntry("otp_code", otp as Any)) assertThat(body, Matchers.hasEntry("auth_session", session as Any)) @@ -467,8 +546,198 @@ public class MyAccountAPIClientTest { assertThat(body, Matchers.hasEntry("type", "push-notification" as Any)) } - private fun bodyFromRequest(request: RecordedRequest): Map { - val mapType = object : TypeToken?>() {}.type + @Test + public fun `enrollPassword should send correct payload`() { + val callback = MockMyAccountCallback() + client.enrollPassword().start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.method, Matchers.equalTo("POST")) + assertThat(body, Matchers.hasEntry("type", "password" as Any)) + } + + @Test + public fun `enrollPassword should include userIdentity and connection parameters`() { + val callback = MockMyAccountCallback() + client.enrollPassword(USER_IDENTITY, CONNECTION).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("type", "password" as Any)) + assertThat(body, Matchers.hasEntry("identity_user_id", USER_IDENTITY as Any)) + assertThat(body, Matchers.hasEntry("connection", CONNECTION as Any)) + } + + @Test + public fun `enrollPassword should parse challenge with policy`() { + mockAPI.willReturnPasswordEnrollmentChallenge() + val challenge = client.enrollPassword().execute() + mockAPI.takeRequest() + + assertThat(challenge, Matchers.instanceOf(PasswordEnrollmentChallenge::class.java)) + assertThat(challenge.id, Matchers.equalTo("password|new")) + assertThat(challenge.authSession, Matchers.equalTo("SESSION_ID")) + assertThat(challenge.policy.complexity.minLength, Matchers.equalTo(8)) + assertThat( + challenge.policy.complexity.characterTypes, + Matchers.contains("uppercase", "lowercase", "number", "special") + ) + assertThat( + challenge.policy.complexity.characterTypeRule, + Matchers.equalTo("three_of_four") + ) + assertThat(challenge.policy.profileData.active, Matchers.equalTo(true)) + assertThat(challenge.policy.profileData.blockedFields, Matchers.contains("name", "email")) + assertThat(challenge.policy.history.size, Matchers.equalTo(5)) + assertThat(challenge.policy.dictionary.default, Matchers.equalTo("en_10k")) + } + + @Test + public fun `verifyPassword should send correct payload`() { + val callback = MockMyAccountCallback() + val methodId = "password|new" + val newPassword = "S3cr3tP@ssw0rd" + val session = "abc-def" + client.verifyPassword(methodId, session, newPassword).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods/password%7Cnew/verify") + ) + assertThat(request.method, Matchers.equalTo("POST")) + assertThat(body, Matchers.hasEntry("new_password", newPassword as Any)) + assertThat(body, Matchers.hasEntry("auth_session", session as Any)) + } + + @Test + public fun `verifyPassword should return PasswordAuthenticationMethod on success`() { + mockAPI.willReturnPasswordAuthenticationMethod() + val response = client.verifyPassword("password|new", AUTH_SESSION, "S3cr3tP@ssw0rd") + .execute() + mockAPI.takeRequest() + + assertThat(response, Matchers.instanceOf(PasswordAuthenticationMethod::class.java)) + assertThat(response.id, Matchers.equalTo("password|pwd_a1b2c3d4e5f6")) + assertThat(response.type, Matchers.equalTo("password")) + assertThat(response.identityUserId, Matchers.equalTo("user_98765432")) + } + + @Test + public fun `enrollPassword should include DPoP proof header on POST when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.enrollPassword().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("POST")) + } + + // DPoP tests + + @Test + public fun `should use Bearer authorization header when DPoP is not enabled`() { + val callback = MockMyAccountCallback>() + client.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + } + + @Test + public fun `should include DPoP proof header on POST requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("POST")) + } + + @Test + public fun `should not include DPoP proof header when DPoP is not enabled`() { + val callback = MockMyAccountCallback() + client.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should not include DPoP proof header when DPoP is enabled but no key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header on PATCH requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.updateAuthenticationMethodById("method|123", authenticationMethodName = "Test") + .start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("PATCH")) + } + + @Test + public fun `should use DPoP authorization header on DELETE requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.deleteAuthenticationMethod("method|123").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("DELETE")) + } + + private inline fun bodyFromRequest(request: RecordedRequest): Map { + val mapType = object : TypeToken>() {}.type return gson.fromJson(request.body.readUtf8(), mapType) } @@ -497,6 +766,7 @@ public class MyAccountAPIClientTest { private companion object { private const val CLIENT_ID = "CLIENTID" + private const val DOMAIN = "test-domain" private const val USER_IDENTITY = "user123" private const val CONNECTION = "passkey-connection" private const val ACCESS_TOKEN = "accessToken" diff --git a/auth0/src/test/java/com/auth0/android/provider/AuthTabResultHandlerTest.kt b/auth0/src/test/java/com/auth0/android/provider/AuthTabResultHandlerTest.kt new file mode 100644 index 000000000..4e23eb960 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/AuthTabResultHandlerTest.kt @@ -0,0 +1,100 @@ +package com.auth0.android.provider + +import android.app.Activity +import android.net.Uri +import androidx.browser.auth.AuthTabIntent +import com.auth0.android.authentication.AuthenticationException +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.Mockito.mock + +public class AuthTabResultHandlerTest { + + @Test + public fun shouldCallOnSuccessWithUriOnResultOk() { + val uri = mock(Uri::class.java) + var deliveredUri: Uri? = null + val handler = AuthTabResultHandler( + onSuccess = { deliveredUri = it }, + onFailure = { fail("unexpected failure") }, + onCancel = { fail("unexpected cancel") } + ) + + handler.handle(Activity.RESULT_OK, uri) + + assertThat(deliveredUri, `is`(uri)) + } + + @Test + public fun shouldCallOnSuccessWithNullUriWhenResultOkHasNoUri() { + var deliveredUri: Uri? = mock(Uri::class.java) + val handler = AuthTabResultHandler( + onSuccess = { deliveredUri = it }, + onFailure = { fail("unexpected failure") }, + onCancel = { fail("unexpected cancel") } + ) + + handler.handle(Activity.RESULT_OK, null) + + assertNull(deliveredUri) + } + + @Test + public fun shouldCallOnCancelOnResultCanceled() { + var cancelCalled = false + val handler = AuthTabResultHandler( + onSuccess = { fail("unexpected success") }, + onFailure = { fail("unexpected failure") }, + onCancel = { cancelCalled = true } + ) + + handler.handle(Activity.RESULT_CANCELED, null) + + assertThat(cancelCalled, `is`(true)) + } + + @Test + public fun shouldCallOnFailureOnResultVerificationFailed() { + var error: AuthenticationException? = null + val handler = AuthTabResultHandler( + onSuccess = { fail("unexpected success") }, + onFailure = { error = it }, + onCancel = { fail("unexpected cancel") } + ) + + handler.handle(AuthTabIntent.RESULT_VERIFICATION_FAILED, null) + + assertThat(error?.getCode(), `is`("a0.auth_tab_verification_failed")) + } + + @Test + public fun shouldCallOnFailureOnResultVerificationTimedOut() { + var error: AuthenticationException? = null + val handler = AuthTabResultHandler( + onSuccess = { fail("unexpected success") }, + onFailure = { error = it }, + onCancel = { fail("unexpected cancel") } + ) + + handler.handle(AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT, null) + + assertThat(error?.getCode(), `is`("a0.auth_tab_verification_failed")) + } + + @Test + public fun shouldCallOnCancelOnUnknownResultCode() { + var cancelCalled = false + val handler = AuthTabResultHandler( + onSuccess = { fail("unexpected success") }, + onFailure = { fail("unexpected failure") }, + onCancel = { cancelCalled = true } + ) + + handler.handle(AuthTabIntent.RESULT_UNKNOWN_CODE, null) + + assertThat(cancelCalled, `is`(true)) + } +} diff --git a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt index b78c3aadf..8e8d5999e 100644 --- a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Parcelable +import androidx.browser.auth.AuthTabIntent import androidx.test.espresso.intent.matcher.IntentMatchers import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.RunnableTask @@ -13,7 +14,7 @@ import com.auth0.android.provider.AuthenticationActivity.Companion.EXTRA_AUTHORI import com.auth0.android.provider.AuthenticationActivity.Companion.EXTRA_CT_OPTIONS import com.auth0.android.provider.AuthenticationActivity.Companion.EXTRA_LAUNCH_AS_TWA import com.auth0.android.provider.CustomTabsOptions -import com.nhaarman.mockitokotlin2.any +import org.mockito.kotlin.any import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert import org.hamcrest.Matchers @@ -214,13 +215,12 @@ public class AuthenticationActivityTest { MatcherAssert.assertThat(launchAsTwaCaptor.value, Is.`is`(false)) MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.nullValue())) activityController.pause().stop() - //Browser is shown activityController.start().resume() MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat( activity.deliveredIntent!!.data, Is.`is`(Matchers.nullValue()) - ) //null data == canceled + ) MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true)) activityController.destroy() Mockito.verify(customTabsController).unbindService() @@ -307,6 +307,60 @@ public class AuthenticationActivityTest { activityController.destroy() } + @Test + public fun shouldDeliverResultWhenAuthTabSucceeds() { + AuthenticationActivity.authenticateUsingBrowser( + callerActivity, uri, false, customTabsOptions + ) + Mockito.verify(callerActivity).startActivity(intentCaptor.capture()) + createActivity(intentCaptor.value) + activityController.create().start().resume() + + activity.authTabResultHandler.handle(Activity.RESULT_OK, resultUri) + + MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(activity.deliveredIntent!!.data, Is.`is`(resultUri)) + MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true)) + activityController.destroy() + } + + @Test + public fun shouldDeliverCanceledWhenAuthTabCanceled() { + AuthenticationActivity.authenticateUsingBrowser( + callerActivity, uri, false, customTabsOptions + ) + Mockito.verify(callerActivity).startActivity(intentCaptor.capture()) + createActivity(intentCaptor.value) + activityController.create().start().resume() + + activity.authTabResultHandler.handle(Activity.RESULT_CANCELED, null) + + MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(activity.deliveredIntent!!.data, Is.`is`(Matchers.nullValue())) + MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true)) + activityController.destroy() + } + + @Test + public fun shouldDeliverFailureWhenAuthTabVerificationFails() { + AuthenticationActivity.authenticateUsingBrowser( + callerActivity, uri, false, customTabsOptions + ) + Mockito.verify(callerActivity).startActivity(intentCaptor.capture()) + createActivity(intentCaptor.value) + activityController.create().start().resume() + + activity.authTabResultHandler.handle(AuthTabIntent.RESULT_VERIFICATION_FAILED, null) + + MatcherAssert.assertThat(activity.deliveredException, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat( + activity.deliveredException!!.getCode(), + Is.`is`("a0.auth_tab_verification_failed") + ) + MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true)) + activityController.destroy() + } + private fun recreateAndCallNewIntent(data: Intent) { val outState = Bundle() activityController.saveInstanceState(outState) diff --git a/auth0/src/test/java/com/auth0/android/provider/BrowserPickerTest.java b/auth0/src/test/java/com/auth0/android/provider/BrowserPickerTest.java index 728d62f62..19704c517 100644 --- a/auth0/src/test/java/com/auth0/android/provider/BrowserPickerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/BrowserPickerTest.java @@ -29,8 +29,8 @@ import static org.hamcrest.Matchers.isOneOf; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.core.IsNull.notNullValue; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java index bdbfbf5f9..172f41c63 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java @@ -1,5 +1,28 @@ package com.auth0.android.provider; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasFlag; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.Is.is; +import static org.hamcrest.core.Is.isA; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import androidx.activity.result.ActivityResultLauncher; + import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ComponentName; @@ -8,7 +31,6 @@ import android.content.ServiceConnection; import android.graphics.Color; import android.net.Uri; -import android.os.Looper; import androidx.annotation.NonNull; import androidx.browser.customtabs.CustomTabsCallback; @@ -16,60 +38,41 @@ import androidx.browser.customtabs.CustomTabsIntent; import androidx.browser.customtabs.CustomTabsServiceConnection; import androidx.browser.customtabs.CustomTabsSession; -import androidx.browser.trusted.TrustedWebActivityDisplayMode; import androidx.browser.trusted.TrustedWebActivityIntentBuilder; +import com.auth0.android.authentication.AuthenticationException; +import com.auth0.android.request.internal.ThreadSwitcher; +import com.google.androidbrowserhelper.trusted.TwaLauncher; +import com.google.androidbrowserhelper.trusted.splashscreens.SplashScreenStrategy; + +import org.junit.After; import org.junit.Before; -import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import org.powermock.api.mockito.PowerMockito; +import org.mockito.quality.Strictness; +import org.mockito.stubbing.Answer; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import java.util.List; - -import static androidx.test.espresso.intent.matcher.IntentMatchers.hasFlag; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.notNullValue; -import static org.hamcrest.Matchers.nullValue; -import static org.hamcrest.core.Is.is; -import static org.hamcrest.core.Is.isA; -import static org.mockito.ArgumentMatchers.same; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.timeout; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.auth0.android.authentication.AuthenticationException; -import com.auth0.android.request.internal.CommonThreadSwitcher; -import com.auth0.android.request.internal.ThreadSwitcher; -import com.auth0.android.util.CommonThreadSwitcherRule; -import com.google.androidbrowserhelper.trusted.TwaLauncher; -import com.google.androidbrowserhelper.trusted.splashscreens.SplashScreenStrategy; +import java.util.concurrent.atomic.AtomicReference; @RunWith(RobolectricTestRunner.class) public class CustomTabsControllerTest { private static final String DEFAULT_BROWSER_PACKAGE = "com.auth0.browser"; private static final long MAX_TEST_WAIT_TIME_MS = 2000; + private static final String AUTH_URL_WITH_REDIRECT = + "https://example.auth0.com/authorize?redirect_uri=myapp%3A%2F%2Fcallback"; private Context context; + private MockedStatic customTabsClientMock; @Mock private Uri uri; @Mock @@ -88,6 +91,14 @@ public class CustomTabsControllerTest { private CustomTabsController controller; + @After + public void tearDown() { + if (customTabsClientMock != null) { + customTabsClientMock.close(); + customTabsClientMock = null; + } + } + @Before public void setUp() { MockitoAnnotations.openMocks(this); @@ -110,7 +121,7 @@ public void backgroundThread(@NonNull Runnable runnable) { when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE); CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder().withBrowserPicker(browserPicker).build(); - controller = new CustomTabsController(context, ctOptions, twaLauncher); + controller = new CustomTabsController(context, ctOptions, twaLauncher, null); } @Test @@ -201,7 +212,7 @@ public void shouldLaunchUriUsingFallbackWhenNoCompatibleBrowserIsAvailable() { BrowserPicker browserPicker = mock(BrowserPicker.class); when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(null); CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder().withBrowserPicker(browserPicker).build(); - CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher); + CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher, null); controller.launchUri(uri, false, mockThreadSwitcher, null); verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); @@ -223,7 +234,7 @@ public void shouldBindAndLaunchUriWithCustomization() throws Exception { .withToolbarColor(android.R.color.black) .withBrowserPicker(browserPicker) .build(); - CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher); + CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher, null); bindService(controller, true); connectBoundService(); @@ -253,7 +264,7 @@ public void shouldBindAndLaunchUriWithCustomizationTwa() throws Exception { .withToolbarColor(android.R.color.black) .withBrowserPicker(browserPicker) .build(); - CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher); + CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher, null); bindService(controller, true); controller.launchUri(uri, true, mockThreadSwitcher, null); @@ -345,6 +356,176 @@ public void shouldThrowExceptionIfFailedToLaunchBecauseOfException() { }); } + // --- Auth Tab --- + + @Test + public void shouldLaunchAsAuthTabWhenSupportedByBrowser() throws Exception { + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> + CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE)) + ).thenReturn(true); + + @SuppressWarnings("unchecked") + ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class); + + BrowserPicker browserPicker = mock(BrowserPicker.class); + when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE); + CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withAuthTab() + .build(); + + CustomTabsController authTabController = + new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher); + + Uri authorizeUri = Uri.parse(AUTH_URL_WITH_REDIRECT); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null); + + verify(mockAuthTabLauncher, timeout(MAX_TEST_WAIT_TIME_MS)).launch(any(Intent.class)); + verify(context, never()).startActivity(any(Intent.class)); + } + + @Test + public void shouldFallbackToCustomTabWhenAuthTabNotSupportedByBrowser() { + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> + CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE)) + ).thenReturn(false); + + @SuppressWarnings("unchecked") + ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class); + + BrowserPicker browserPicker = mock(BrowserPicker.class); + when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE); + CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withAuthTab() + .build(); + + CustomTabsController authTabController = + new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher); + doReturn(false).when(context).bindService(any(), any(), anyInt()); + + Uri authorizeUri = Uri.parse(AUTH_URL_WITH_REDIRECT); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null); + + verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); + verify(mockAuthTabLauncher, never()).launch(any(Intent.class)); + } + + @Test + public void shouldFallbackToCustomTabWhenNoPreferredBrowserForAuthTab() { + @SuppressWarnings("unchecked") + ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class); + + BrowserPicker browserPicker = mock(BrowserPicker.class); + when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(null); + CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withAuthTab() + .build(); + + CustomTabsController authTabController = + new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher); + + Uri authorizeUri = Uri.parse(AUTH_URL_WITH_REDIRECT); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null); + + verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); + verify(mockAuthTabLauncher, never()).launch(any(Intent.class)); + } + + @Test + public void shouldFallbackToCustomTabWhenAuthTabLauncherIsNull() { + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> + CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE)) + ).thenReturn(true); + + BrowserPicker browserPicker = mock(BrowserPicker.class); + when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE); + CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withAuthTab() + .build(); + + // Pass null authTabLauncher — simulates launcher not being registered + CustomTabsController authTabController = + new CustomTabsController(context, ctOptions, twaLauncher, null); + doReturn(false).when(context).bindService(any(), any(), anyInt()); + + Uri authorizeUri = Uri.parse(AUTH_URL_WITH_REDIRECT); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null); + + verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); + } + + @Test + public void shouldReturnErrorWhenRedirectUriIsMissing() { + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> + CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE)) + ).thenReturn(true); + + @SuppressWarnings("unchecked") + ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class); + + BrowserPicker browserPicker = mock(BrowserPicker.class); + when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE); + CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withAuthTab() + .build(); + + CustomTabsController authTabController = + new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher); + + // No redirect_uri query parameter at all + Uri authorizeUri = Uri.parse("https://example.auth0.com/authorize?response_type=code"); + AtomicReference capturedException = new AtomicReference<>(); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, capturedException::set); + + verify(mockThreadSwitcher, timeout(MAX_TEST_WAIT_TIME_MS)).mainThread(any()); + assertThat(capturedException.get(), is(notNullValue())); + assertThat(capturedException.get().getCode(), is("a0.invalid_authorize_url")); + assertThat(capturedException.get().getDescription(), is("Could not determine redirect URI from authorize URL")); + verify(context, never()).startActivity(any(Intent.class)); + verify(mockAuthTabLauncher, never()).launch(any(Intent.class)); + } + + @Test + public void shouldReturnErrorWhenRedirectUriHasNoScheme() { + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> + CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE)) + ).thenReturn(true); + + @SuppressWarnings("unchecked") + ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class); + + BrowserPicker browserPicker = mock(BrowserPicker.class); + when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE); + CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withAuthTab() + .build(); + + CustomTabsController authTabController = + new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher); + + // redirect_uri query param present but has no scheme — Uri.parse("callback").getScheme() == null + Uri authorizeUri = Uri.parse("https://example.auth0.com/authorize?redirect_uri=callback"); + AtomicReference capturedException = new AtomicReference<>(); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, capturedException::set); + + verify(mockThreadSwitcher, timeout(MAX_TEST_WAIT_TIME_MS)).mainThread(any()); + assertThat(capturedException.get(), is(notNullValue())); + assertThat(capturedException.get().getCode(), is("a0.invalid_authorize_url")); + assertThat(capturedException.get().getDescription(), is("Could not determine scheme from redirect URI: callback")); + verify(context, never()).startActivity(any(Intent.class)); + verify(mockAuthTabLauncher, never()).launch(any(Intent.class)); + } + //Helper Methods @SuppressWarnings("WrongConstant") @@ -359,10 +540,16 @@ private void bindService(CustomTabsController controller, boolean willSucceed) { } private void connectBoundService() throws Exception { - CustomTabsSession session = mock(CustomTabsSession.class); - ComponentName componentName = new ComponentName(DEFAULT_BROWSER_PACKAGE, DEFAULT_BROWSER_PACKAGE + ".CustomTabsService"); - //This depends on an implementation detail but is the only way to test it because of methods visibility - PowerMockito.when(session, "getComponentName").thenReturn(componentName); + final ComponentName componentName = new ComponentName(DEFAULT_BROWSER_PACKAGE, DEFAULT_BROWSER_PACKAGE + ".CustomTabsService"); + + CustomTabsSession session = mock(CustomTabsSession.class, withSettings() + .strictness(Strictness.LENIENT) + .defaultAnswer((Answer) invocation -> { + if ("getComponentName".equals(invocation.getMethod().getName())) { + return componentName; + } + return null; + })); when(customTabsClient.newSession(eq(null))).thenReturn(session); CustomTabsServiceConnection conn = serviceConnectionCaptor.getValue(); diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java index c4f03efb4..aa2808f1d 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -5,17 +5,20 @@ import android.content.pm.PackageManager; import android.os.Parcel; +import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsIntent; import androidx.core.content.ContextCompat; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLog; -import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -24,7 +27,8 @@ import static org.hamcrest.core.IsNull.notNullValue; import static org.hamcrest.core.IsNull.nullValue; import static org.junit.Assert.assertEquals; -import static org.mockito.Matchers.any; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; @@ -33,10 +37,20 @@ public class CustomTabsOptionsTest { private Activity context; + private MockedStatic customTabsClientMock; @Before public void setUp() { context = Robolectric.setupActivity(Activity.class); + ShadowLog.clear(); + } + + @After + public void tearDown() { + if (customTabsClientMock != null) { + customTabsClientMock.close(); + customTabsClientMock = null; + } } @Test @@ -87,6 +101,13 @@ public void shouldHaveDefaultValues() { assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.NO_TITLE)); assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_SHARE_STATE, CustomTabsIntent.SHARE_STATE_OFF), is(CustomTabsIntent.SHARE_STATE_OFF)); + // Should not have partial custom tab extras when not set + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); + Parcel parcel = Parcel.obtain(); options.writeToParcel(parcel, 0); parcel.setDataPosition(0); @@ -100,6 +121,13 @@ public void shouldHaveDefaultValues() { assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0), is(0)); assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.NO_TITLE)); assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_SHARE_STATE, CustomTabsIntent.SHARE_STATE_OFF), is(CustomTabsIntent.SHARE_STATE_OFF)); + + // Parceled intent should also not have partial custom tab extras + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); } @Test @@ -187,7 +215,7 @@ public void shouldSetDisabledCustomTabPackages() { CustomTabsOptions options = CustomTabsOptions.newBuilder() .withBrowserPicker(browserPicker) - .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) .withToolbarColor(android.R.color.black) .build(); assertThat(options, is(notNullValue())); @@ -214,7 +242,7 @@ public void shouldSetDisabledCustomTabPackages() { CustomTabsOptions options2 = CustomTabsOptions.newBuilder() .withBrowserPicker(browserPicker2) - .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) .withToolbarColor(android.R.color.black) .build(); @@ -224,4 +252,495 @@ public void shouldSetDisabledCustomTabPackages() { int resolvedColor = ContextCompat.getColor(activity, android.R.color.black); assertThat(intentWithToolbarExtra.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0), is(resolvedColor)); } -} \ No newline at end of file + + @Test + public void shouldSetEphemeralBrowsingWhenSupported() { + Activity activity = spy(Robolectric.setupActivity(Activity.class)); + BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.android.chrome"), null, null); + + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> + CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome")) + ).thenReturn(true); + + BrowserPicker browserPicker = BrowserPicker.newBuilder().build(); + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withEphemeralBrowsing() + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(activity, null); + assertThat(intent, is(notNullValue())); + + // Verify ephemeral browsing extra is set on the intent + assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(true)); + + // Verify isEphemeralBrowsingSupported was called + customTabsClientMock.verify(() -> + CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome")) + ); + + // Verify no warning was logged + assertThat(hasLogWithMessage("Ephemeral browsing was requested"), is(false)); + + // Verify Parcelable round-trip preserves the ephemeral flag + customTabsClientMock.close(); + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> + CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome")) + ).thenReturn(true); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + assertThat(parceledOptions, is(notNullValue())); + + Intent parceledIntent = parceledOptions.toIntent(activity, null); + assertThat(parceledIntent, is(notNullValue())); + + // Verify ephemeral browsing extra is set after Parcel round-trip + assertThat(parceledIntent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(true)); + + // Verify isEphemeralBrowsingSupported was called again after Parcel round-trip + customTabsClientMock.verify(() -> + CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome")) + ); + } + + @Test + public void shouldHaveEphemeralBrowsingDisabledByDefault() { + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + + CustomTabsOptions options = CustomTabsOptions.newBuilder().build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + + customTabsClientMock.verifyNoInteractions(); + + // Verify ephemeral browsing extra is not set + assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(false)); + + assertThat(hasLogWithMessage("Ephemeral browsing was requested"), is(false)); + } + + @Test + public void shouldFallbackWithWarningWhenEphemeralNotSupported() { + Activity activity = spy(Robolectric.setupActivity(Activity.class)); + BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.android.chrome"), null, null); + + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> + CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome")) + ).thenReturn(false); + + BrowserPicker browserPicker = BrowserPicker.newBuilder().build(); + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withEphemeralBrowsing() + .build(); + + Intent intent = options.toIntent(activity, null); + assertThat(intent, is(notNullValue())); + + assertThat(hasLogWithMessage("Ephemeral browsing was requested but is not supported"), is(true)); + + // Verify ephemeral browsing extra is not set (fallback to regular Custom Tab) + assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(false)); + + // Verify the intent still has standard Custom Tab extras (it's a regular Custom Tab) + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE), is(true)); + } + + @Test + public void shouldFallbackWithWarningWhenPreferredPackageIsNull() { + BrowserPicker browserPicker = mock(BrowserPicker.class); + when(browserPicker.getBestBrowserPackage(any(PackageManager.class))).thenReturn(null); + + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withEphemeralBrowsing() + .build(); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + + // Verify ephemeral browsing extra is not set (fallback to regular Custom Tab) + assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(false)); + + // Verify the warning was logged with null package info + assertThat(hasLogWithMessage("Ephemeral browsing was requested but is not supported"), is(true)); + assertThat(hasLogWithMessage("(null)"), is(true)); + } + + @Test + public void shouldIgnoreEphemeralBrowsingWhenDisabledCustomTabBrowser() { + Activity activity = spy(Robolectric.setupActivity(Activity.class)); + BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.auth0.browser"), null, null); + BrowserPicker browserPicker = BrowserPicker.newBuilder().build(); + + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withEphemeralBrowsing() + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(activity, null); + + // Should return a plain ACTION_VIEW intent with no extras + assertThat(intent, is(notNullValue())); + assertThat(intent.getExtras(), is(nullValue())); + assertEquals(intent.getAction(), "android.intent.action.VIEW"); + + // No warning should be logged since the entire Custom Tab path is skipped + assertThat(hasLogWithMessage("Ephemeral browsing was requested"), is(false)); + } + + // --- Partial Custom Tabs: Bottom Sheet --- + // Note: Robolectric uses density=1.0 (mdpi) by default, so dp values equal px values in tests. + + @Test + public void shouldSetInitialActivityHeight() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + } + + @Test + public void shouldSetInitialActivityHeightWithFixedResize() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withResizable(false) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(600)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(600)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + } + + @Test + public void shouldSetInitialActivityHeightWithAdjustableResize() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withResizable(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE)); + } + + @Test + public void shouldSetToolbarCornerRadius() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(16) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + } + + @Test + public void shouldClampToolbarCornerRadiusAboveMax() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(50) + .build(); + + Intent intent = options.toIntent(context, null); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + } + + @Test + public void shouldClampNegativeToolbarCornerRadiusToZero() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(-10) + .build(); + + Intent intent = options.toIntent(context, null); + // Clamped to 0; since the toIntent guard is `if (toolbarCornerRadius > 0)`, no extra is set. + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + } + + // --- Partial Custom Tabs: Side Sheet --- + // Note: withInitialWidth accepts dp; converted to px internally (1:1 in Robolectric mdpi). + + @Test + public void shouldSetInitialActivityWidth() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withInitialWidth(400) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(400)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(400)); + } + + @Test + public void shouldSetSideSheetBreakpoint() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withInitialWidth(400) + .withSideSheetBreakpoint(840) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + } + + // --- Partial Custom Tabs: Background Interaction --- + + @Test + public void shouldSetBackgroundInteractionEnabled() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withBackgroundInteractionEnabled(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + // When background interaction is enabled, EXTRA_DISABLE_BACKGROUND_INTERACTION should be present and false + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION, true), is(false)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + assertThat(parceledIntent.getBooleanExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION, true), is(false)); + } + + @Test + public void shouldNotSetBackgroundInteractionWhenDisabled() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withBackgroundInteractionEnabled(false) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); + } + + // --- Combined Partial Custom Tabs --- + + @Test + public void shouldSetAllPartialCustomTabOptions() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withToolbarColor(android.R.color.black) + .showTitle(true) + .withInitialHeight(800) + .withResizable(false) + .withToolbarCornerRadius(16) + .withInitialWidth(500) + .withSideSheetBreakpoint(840) + .withBackgroundInteractionEnabled(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + + // Existing options + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.SHOW_PAGE_TITLE)); + + // Bottom sheet + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + + // Side sheet + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(500)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + + // Background interaction + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + + // Verify parceling preserves everything + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(500)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + } + + @Test + public void shouldNotSetPartialOptionsWhenDisabledBrowser() { + Activity activity = spy(Robolectric.setupActivity(Activity.class)); + BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.auth0.browser"), null, null); + BrowserPicker browserPicker = BrowserPicker.newBuilder().build(); + + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) + .withInitialHeight(800) + .withToolbarCornerRadius(16) + .build(); + assertThat(options, is(notNullValue())); + + Intent intentNoExtras = options.toIntent(activity, null); + assertThat(intentNoExtras, is(notNullValue())); + assertThat(intentNoExtras.getExtras(), is(nullValue())); + assertEquals(intentNoExtras.getAction(), "android.intent.action.VIEW"); + } + + // --- Auth Tab --- + + @Test + public void shouldHaveAuthTabDisabledByDefault() { + CustomTabsOptions options = CustomTabsOptions.newBuilder().build(); + assertThat(options.isAuthTab(), is(false)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + assertThat(parceledOptions.isAuthTab(), is(false)); + } + + @Test + public void shouldSetAuthTab() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withAuthTab() + .build(); + assertThat(options.isAuthTab(), is(true)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + assertThat(parceledOptions.isAuthTab(), is(true)); + } + + @Test + public void shouldCopyWithEphemeralBrowsingPreservesAuthTab() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withAuthTab().build(); + assertThat(options.isAuthTab(), is(true)); + + CustomTabsOptions copied = options.copyWithEphemeralBrowsing(); + assertThat(copied.isAuthTab(), is(true)); + } + + @Test + public void shouldCopyWithAuthTab() { + CustomTabsOptions options = CustomTabsOptions.newBuilder().build(); + assertThat(options.isAuthTab(), is(false)); + + CustomTabsOptions copied = options.copyWithAuthTab(); + assertThat(copied.isAuthTab(), is(true)); + } + + @Test + public void shouldCopyWithAuthTabPreservesEphemeralBrowsing() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withEphemeralBrowsing().build(); + + CustomTabsOptions copied = options.copyWithAuthTab(); + assertThat(copied.isAuthTab(), is(true)); + } + + @Test + public void shouldToBuilderPreserveAllFields() { + CustomTabsOptions original = CustomTabsOptions.newBuilder() + .showTitle(true) + .withToolbarColor(android.R.color.black) + .withEphemeralBrowsing().withAuthTab().build(); + + CustomTabsOptions rebuilt = original.toBuilder().build(); + + assertThat(rebuilt.isAuthTab(), is(true)); + } + + /** + * Helper to check if a log message containing the given text was emitted. + */ + private boolean hasLogWithMessage(String messageSubstring) { + for (ShadowLog.LogItem item : ShadowLog.getLogs()) { + if (item.msg != null && item.msg.contains(messageSubstring)) { + return true; + } + } + return false; + } +} diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt index e4ac82383..2a32f745f 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -2,7 +2,7 @@ package com.auth0.android.provider import android.graphics.Color import com.auth0.android.Auth0 -import com.nhaarman.mockitokotlin2.mock +import org.mockito.kotlin.mock import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith @@ -44,4 +44,88 @@ internal class OAuthManagerStateTest { Assert.assertEquals(1, deserializedState.idTokenVerificationLeeway) Assert.assertEquals("issuer", deserializedState.idTokenVerificationIssuer) } + + @Test + fun `serialize should persist dPoPEnabled flag as true`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer", + dPoPEnabled = true + ) + + val json = state.serializeToJson() + + Assert.assertTrue(json.isNotBlank()) + Assert.assertTrue(json.contains("\"dPoPEnabled\":true")) + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertTrue(deserializedState.dPoPEnabled) + } + + @Test + fun `serialize should persist dPoPEnabled flag as false by default`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer" + ) + + val json = state.serializeToJson() + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } + + @Test + fun `deserialize should default dPoPEnabled to false when field is missing from JSON`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null + ) + + val json = state.serializeToJson() + // Remove the dPoPEnabled field to simulate legacy JSON + val legacyJson = json.replace(",\"dPoPEnabled\":false", "") + + val deserializedState = OAuthManagerState.deserializeState(legacyJson) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } } diff --git a/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt new file mode 100644 index 000000000..4b576c06c --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt @@ -0,0 +1,235 @@ +package com.auth0.android.provider + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.provider.WebAuthProvider.authorizeWithRequestUri +import com.auth0.android.provider.WebAuthProvider.resume +import com.auth0.android.request.internal.ThreadSwitcherShadow +import com.auth0.android.result.AuthorizationCode +import org.mockito.kotlin.* +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.notNullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ThreadSwitcherShadow::class]) +public class PARCodeManagerTest { + + @Mock + private lateinit var callback: Callback + + private lateinit var activity: Activity + private lateinit var account: Auth0 + + private val authCodeCaptor: KArgumentCaptor = argumentCaptor() + private val authExceptionCaptor: KArgumentCaptor = argumentCaptor() + private val intentCaptor: KArgumentCaptor = argumentCaptor() + + private companion object { + private const val DOMAIN = "samples.auth0.com" + private const val CLIENT_ID = "test-client-id" + private const val REQUEST_URI = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + private const val AUTH_CODE = "test-authorization-code" + private const val SESSION_TRANSFER_TOKEN = "test-session-transfer-token" + } + + @Before + public fun setUp() { + MockitoAnnotations.openMocks(this) + activity = Mockito.spy(Robolectric.buildActivity(Activity::class.java).get()) + account = Auth0.getInstance(CLIENT_ID, DOMAIN) + + Mockito.doReturn(false).`when`(activity).bindService( + any(), + any(), + ArgumentMatchers.anyInt() + ) + BrowserPickerTest.setupBrowserContext( + activity, + listOf("com.auth0.browser"), + null, + null + ) + } + + @Test + public fun shouldStartPARFlowWithCorrectAuthorizeUri() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + Assert.assertNotNull(WebAuthProvider.managerInstance) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.scheme, `is`("https")) + assertThat(uri?.host, `is`(DOMAIN)) + assertThat(uri?.path, `is`("/authorize")) + assertThat(uri?.getQueryParameter("client_id"), `is`(CLIENT_ID)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(REQUEST_URI)) + } + + @Test + public fun shouldResumeWithValidCode() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("code=$AUTH_CODE") + + Assert.assertTrue(resume(intent)) + + verify(callback).onSuccess(authCodeCaptor.capture()) + val authCode = authCodeCaptor.firstValue + assertThat(authCode, `is`(notNullValue())) + assertThat(authCode.code, `is`(AUTH_CODE)) + } + + @Test + public fun shouldResumeWithCodeAndStateFromRedirect() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("code=$AUTH_CODE&state=par-state") + + Assert.assertTrue(resume(intent)) + + verify(callback).onSuccess(authCodeCaptor.capture()) + val authCode = authCodeCaptor.firstValue + assertThat(authCode.code, `is`(AUTH_CODE)) + assertThat(authCode.state, `is`("par-state")) + } + + @Test + public fun shouldFailWithMissingCode() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("foo=bar") + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isAccessDenied, `is`(true)) + } + + @Test + public fun shouldFailWithErrorResponse() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("error=access_denied&error_description=User%20denied%20access") + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.getCode(), `is`("access_denied")) + } + + @Test + public fun shouldHandleCanceledAuthentication() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = Intent() + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isCanceled, `is`(true)) + } + + @Test + public fun shouldFailWhenNoBrowserAvailable() { + BrowserPickerTest.setupBrowserContext( + activity, + emptyList(), + null, + null + ) + + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isBrowserAppNotAvailable, `is`(true)) + } + + @Test + public fun shouldIncludeSessionTransferTokenInAuthorizeUri() { + authorizeWithRequestUri(account) + .withSessionTransferToken(SESSION_TRANSFER_TOKEN) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(CLIENT_ID)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(REQUEST_URI)) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(SESSION_TRANSFER_TOKEN)) + } + + @Test + public fun shouldNotIncludeSessionTransferTokenWhenNotProvided() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(org.hamcrest.CoreMatchers.nullValue())) + } + + @Test + public fun shouldFailWithInvalidRequestUri() { + authorizeWithRequestUri(account) + .start(activity, "invalid-request-uri", callback) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.getCode(), `is`("a0.invalid_request_uri")) + } + + private fun createAuthIntent(queryString: String): Intent { + val uri = Uri.parse("https://$DOMAIN/android/com.auth0.test/callback?$queryString") + return Intent().apply { + data = uri + } + } +} diff --git a/auth0/src/test/java/com/auth0/android/provider/PKCETest.java b/auth0/src/test/java/com/auth0/android/provider/PKCETest.java index f352324fc..d6d1ad58c 100644 --- a/auth0/src/test/java/com/auth0/android/provider/PKCETest.java +++ b/auth0/src/test/java/com/auth0/android/provider/PKCETest.java @@ -29,8 +29,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/auth0/src/test/java/com/auth0/android/provider/PasskeyManagerTest.kt b/auth0/src/test/java/com/auth0/android/provider/PasskeyManagerTest.kt deleted file mode 100644 index 3f99b78da..000000000 --- a/auth0/src/test/java/com/auth0/android/provider/PasskeyManagerTest.kt +++ /dev/null @@ -1,423 +0,0 @@ -package com.auth0.android.provider - -import android.content.Context -import androidx.credentials.CreateCredentialResponse -import androidx.credentials.CreatePublicKeyCredentialResponse -import androidx.credentials.CredentialManager -import androidx.credentials.CredentialManagerCallback -import androidx.credentials.GetCredentialRequest -import androidx.credentials.GetCredentialResponse -import androidx.credentials.PublicKeyCredential -import androidx.credentials.exceptions.CreateCredentialException -import androidx.credentials.exceptions.CreateCredentialInterruptedException -import androidx.credentials.exceptions.GetCredentialException -import androidx.credentials.exceptions.GetCredentialInterruptedException -import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.authentication.AuthenticationException -import com.auth0.android.authentication.request.AuthenticationRequestMock -import com.auth0.android.authentication.request.RequestMock -import com.auth0.android.callback.Callback -import com.auth0.android.request.PublicKeyCredentials -import com.auth0.android.request.UserData -import com.auth0.android.result.AuthParamsPublicKey -import com.auth0.android.result.AuthenticatorSelection -import com.auth0.android.result.AuthnParamsPublicKey -import com.auth0.android.result.Credentials -import com.auth0.android.result.PasskeyChallenge -import com.auth0.android.result.PasskeyRegistrationChallenge -import com.auth0.android.result.PasskeyUser -import com.auth0.android.result.PubKeyCredParam -import com.auth0.android.result.RelyingParty -import com.nhaarman.mockitokotlin2.KArgumentCaptor -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.doAnswer -import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.Mockito.`when` -import org.mockito.MockitoAnnotations -import org.robolectric.RobolectricTestRunner -import java.util.Date -import java.util.concurrent.Executor - - -@RunWith(RobolectricTestRunner::class) -public class PasskeyManagerTest { - - private lateinit var passkeyManager: PasskeyManager - - @Mock - private lateinit var callback: Callback - - @Mock - private lateinit var authenticationAPIClient: AuthenticationAPIClient - - @Mock - private lateinit var credentialManager: CredentialManager - - @Mock - private lateinit var context: Context - - private val serialExecutor = Executor { runnable -> runnable.run() } - - private val credentialsCaptor: KArgumentCaptor = argumentCaptor() - private val exceptionCaptor: KArgumentCaptor = argumentCaptor() - - - private val passkeyRegistrationChallengeResponse = PasskeyRegistrationChallenge( - authSession = "dummyAuthSession", - authParamsPublicKey = AuthnParamsPublicKey( - authenticatorSelection = AuthenticatorSelection( - residentKey = "required", - userVerification = "preferred" - ), - challenge = "dummyChallenge", - pubKeyCredParams = listOf( - PubKeyCredParam( - alg = -7, - type = "public-key" - ) - ), - relyingParty = RelyingParty( - id = "dummyRpId", - name = "dummyRpName" - ), - timeout = 60000L, - user = PasskeyUser( - displayName = "displayName", - id = "userId", - name = "userName" - ) - ) - ) - - private val registrationResponseJSON = """ - { - "id": "id", - "rawId": "rawId", - "response": { - "attestationObject": "attnObject", - "clientDataJSON": "dataJSON" - }, - "type": "public-key" - } - """ - - private val passkeyChallenge = PasskeyChallenge( - authSession = "authSession", - authParamsPublicKey = AuthParamsPublicKey( - challenge = "challenge", - rpId = "RpId", - timeout = 60000, - userVerification = "preferred" - ) - ) - - @Before - public fun setUp() { - MockitoAnnotations.openMocks(this) - passkeyManager = PasskeyManager(authenticationAPIClient, credentialManager) - } - - - @Test - public fun shouldSignUpWithPasskeySuccess() { - val userMetadata: UserData = mock() - val parameters = mapOf("scope" to "profile") - - `when`(authenticationAPIClient.signupWithPasskey(userMetadata, "testRealm")).thenReturn( - RequestMock(passkeyRegistrationChallengeResponse, null) - ) - `when`( - authenticationAPIClient.signinWithPasskey( - any(), - any(), - any(), - eq(null) - ) - ).thenReturn( - AuthenticationRequestMock( - Credentials( - "expectedIdToken", - "codeAccess", - "codeType", - "codeRefresh", - Date(), - "codeScope" - ), null - ) - ) - - val createResponse: CreatePublicKeyCredentialResponse = mock() - `when`(createResponse.registrationResponseJson).thenReturn( - registrationResponseJSON - ) - - whenever( - credentialManager.createCredentialAsync( - any(), - any(), - any(), - any(), - any() - ) - ).thenAnswer { - (it.arguments[4] as CredentialManagerCallback).onResult( - createResponse - ) - } - - passkeyManager.signup( - context, - userMetadata, - "testRealm", - parameters, - callback, - serialExecutor - ) - - verify(authenticationAPIClient).signupWithPasskey(userMetadata, "testRealm") - verify(credentialManager).createCredentialAsync(eq(context), any(), any(), any(), any()) - verify(authenticationAPIClient).signinWithPasskey( - any(), any(), any(), - eq(null) - ) - verify(callback).onSuccess(credentialsCaptor.capture()) - Assert.assertEquals("codeAccess", credentialsCaptor.firstValue.accessToken) - Assert.assertEquals("codeScope", credentialsCaptor.firstValue.scope) - - } - - @Test - public fun shouldSignUpWithPasskeyApiFailure() { - val userMetadata: UserData = mock() - val parameters = mapOf("scope" to "profile") - val error = AuthenticationException("Signup failed") - `when`( - authenticationAPIClient.signupWithPasskey( - userMetadata, - "testRealm" - ) - ).thenReturn(RequestMock(null, error)) - passkeyManager.signup( - context, - userMetadata, - "testRealm", - parameters, - callback, - serialExecutor - ) - verify(authenticationAPIClient).signupWithPasskey(userMetadata, "testRealm") - verify(authenticationAPIClient, never()).signinWithPasskey( - any(), - any(), - any(), - eq(null) - ) - verify(credentialManager, never()).createCredentialAsync( - any(), - any(), - any(), - any(), - any() - ) - verify(callback).onFailure(error) - } - - @Test - public fun shouldSignUpWithPasskeyCreateCredentialFailure() { - val userMetadata: UserData = mock() - val parameters = mapOf("scope" to "scope") - `when`( - authenticationAPIClient.signupWithPasskey( - userMetadata, - "testRealm" - ) - ).thenReturn(RequestMock(passkeyRegistrationChallengeResponse, null)) - - whenever( - credentialManager.createCredentialAsync( - any(), - any(), - any(), - any(), - any() - ) - ).thenAnswer { - (it.arguments[4] as CredentialManagerCallback).onError( - CreateCredentialInterruptedException() - ) - } - - passkeyManager.signup( - context, - userMetadata, - "testRealm", - parameters, - callback, - serialExecutor - ) - verify(authenticationAPIClient).signupWithPasskey(userMetadata, "testRealm") - verify(credentialManager).createCredentialAsync(eq(context), any(), any(), any(), any()) - verify(authenticationAPIClient, never()).signinWithPasskey( - any(), - any(), - any(), eq(null) - ) - verify(callback).onFailure(exceptionCaptor.capture()) - Assert.assertEquals( - AuthenticationException::class.java, - exceptionCaptor.firstValue.javaClass - ) - Assert.assertEquals( - "Passkey authentication was interrupted. Please retry the call.", - exceptionCaptor.firstValue.message - ) - } - - - @Test - public fun shouldSignInWithPasskeySuccess() { - val parameters = mapOf("scope" to "scope") - val credentialResponse: GetCredentialResponse = mock() - - `when`(authenticationAPIClient.passkeyChallenge("testRealm")).thenReturn( - RequestMock(passkeyChallenge, null) - ) - - `when`(credentialResponse.credential).thenReturn( - PublicKeyCredential(registrationResponseJSON) - ) - - `when`( - authenticationAPIClient.signinWithPasskey( - any(), - any(), - any(), - eq(null) - ) - ).thenReturn( - AuthenticationRequestMock( - Credentials( - "expectedIdToken", - "codeAccess", - "codeType", - "codeRefresh", - Date(), - "codeScope" - ), null - ) - ) - - doAnswer { - val callback = - it.getArgument>( - 4 - ) - callback.onResult(credentialResponse) - }.`when`(credentialManager) - .getCredentialAsync(any(), any(), any(), any(), any()) - - passkeyManager.signin(context, "testRealm", parameters, callback, serialExecutor) - - verify(authenticationAPIClient).passkeyChallenge("testRealm") - verify(credentialManager).getCredentialAsync( - any(), - any(), - any(), - any(), - any() - ) - verify(authenticationAPIClient).signinWithPasskey( - any(), any(), any(), - eq(null) - ) - verify(callback).onSuccess(credentialsCaptor.capture()) - Assert.assertEquals("codeAccess", credentialsCaptor.firstValue.accessToken) - Assert.assertEquals("codeScope", credentialsCaptor.firstValue.scope) - } - - - @Test - public fun shouldSignInWithPasskeyApiFailure() { - val parameters = mapOf("scope" to "profile") - val error = AuthenticationException("Signin failed") - - `when`(authenticationAPIClient.passkeyChallenge("testRealm")).thenReturn( - RequestMock(null, error) - ) - - passkeyManager.signin(context, "testRealm", parameters, callback, serialExecutor) - - verify(authenticationAPIClient).passkeyChallenge(any(), eq(null)) - verify(credentialManager, never()).getCredentialAsync( - any(), - any(), - any(), - any(), - any() - ) - verify(authenticationAPIClient, never()).signinWithPasskey( - any(), - any(), - any(), - eq(null) - ) - verify(callback).onFailure(error) - } - - @Test - public fun shouldSignInWithPasskeyGetCredentialFailure() { - val parameters = mapOf("realm" to "testRealm") - `when`(authenticationAPIClient.passkeyChallenge("testRealm")).thenReturn( - RequestMock(passkeyChallenge, null) - ) - - whenever( - credentialManager.getCredentialAsync( - any(), - any(), - any(), - any(), - any() - ) - ).thenAnswer { - (it.arguments[4] as CredentialManagerCallback).onError( - GetCredentialInterruptedException() - ) - } - - passkeyManager.signin(context, "testRealm", parameters, callback, serialExecutor) - verify(authenticationAPIClient).passkeyChallenge("testRealm") - verify(credentialManager).getCredentialAsync( - any(), - any(), - any(), - any(), - any() - ) - verify(authenticationAPIClient, never()).signinWithPasskey( - any(), - any(), - any(), - eq(null) - ) - verify(callback).onFailure(exceptionCaptor.capture()) - Assert.assertEquals( - AuthenticationException::class.java, - exceptionCaptor.firstValue.javaClass - ) - Assert.assertEquals( - "Passkey authentication was interrupted. Please retry the call.", - exceptionCaptor.firstValue.message - ) - } -} diff --git a/auth0/src/test/java/com/auth0/android/provider/PermissionHandlerTest.java b/auth0/src/test/java/com/auth0/android/provider/PermissionHandlerTest.java index de8ff367e..e0c44bd08 100644 --- a/auth0/src/test/java/com/auth0/android/provider/PermissionHandlerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/PermissionHandlerTest.java @@ -21,9 +21,9 @@ import static org.hamcrest.core.IsCollectionContaining.hasItems; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index d603d3456..196830c44 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2,6 +2,9 @@ package com.auth0.android.provider import android.app.Activity import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -27,7 +30,7 @@ import com.auth0.android.request.internal.ThreadSwitcherShadow import com.auth0.android.result.Credentials import com.auth0.android.util.AuthenticationAPIMockServer import com.auth0.android.util.SSLTestUtils -import com.nhaarman.mockitokotlin2.* +import org.mockito.kotlin.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -113,6 +116,9 @@ public class WebAuthProviderTest { ) `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + // Clear any state left over from previous tests + WebAuthProvider.resetState() } @@ -330,8 +336,8 @@ public class WebAuthProviderTest { @Test public fun enablingDPoPWillGenerateNewKeyPairIfOneDoesNotExist() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(mockKeyStore).generateKeyPair(any(), any()) } @@ -357,8 +363,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -1168,6 +1174,7 @@ public class WebAuthProviderTest { @Test @Throws(Exception::class) + @Suppress("DEPRECATION") public fun shouldResumeLoginWithCustomNetworkingClient() { val networkingClient: NetworkingClient = Mockito.spy(DefaultClient()) val authCallback = mock>() @@ -1536,16 +1543,17 @@ public class WebAuthProviderTest { ) } + @Test @Throws(Exception::class) public fun shouldFailToResumeLoginWhenRSAKeyIsMissingFromJWKSet() { val pkce = Mockito.mock(PKCE::class.java) `when`(pkce.codeChallenge).thenReturn("challenge") - val mockAPI = AuthenticationAPIMockServer() - mockAPI.willReturnEmptyJsonWebKeys() + val networkingClient: NetworkingClient = Mockito.mock(NetworkingClient::class.java) val authCallback = mock>() - val proxyAccount: Auth0 = Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, mockAPI.domain) - proxyAccount.networkingClient = SSLTestUtils.testClient + val proxyAccount = + Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, JwtTestUtils.EXPECTED_BASE_DOMAIN) + proxyAccount.networkingClient = networkingClient login(proxyAccount) .withState("1234567890") .withNonce(JwtTestUtils.EXPECTED_NONCE) @@ -1553,9 +1561,7 @@ public class WebAuthProviderTest { .start(activity, authCallback) val managerInstance = WebAuthProvider.managerInstance as OAuthManager managerInstance.currentTimeInMillis = JwtTestUtils.FIXED_CLOCK_CURRENT_TIME_MS - val jwtBody = JwtTestUtils.createJWTBody() - jwtBody["iss"] = proxyAccount.getDomainUrl() - val expectedIdToken = JwtTestUtils.createTestJWT("RS256", jwtBody) + val expectedIdToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleTEyMyJ9.eyJzdWIiOiJ0ZXN0In0.fakesignature" val intent = createAuthIntent( createHash( null, @@ -1578,12 +1584,18 @@ public class WebAuthProviderTest { Date(), "codeScope" ) + val emptyJwksJson = """{"keys": []}""" + val jwksInputStream: InputStream = ByteArrayInputStream(emptyJwksJson.toByteArray()) + val jwksResponse = ServerResponse(200, jwksInputStream, emptyMap()) + Mockito.doReturn(jwksResponse).`when`(networkingClient).load( + eq(proxyAccount.getDomainUrl() + ".well-known/jwks.json"), + any() + ) Mockito.doAnswer { callbackCaptor.firstValue.onSuccess(codeCredentials) null }.`when`(pkce).getToken(eq("1234"), callbackCaptor.capture()) Assert.assertTrue(resume(intent)) - mockAPI.takeRequest() ShadowLooper.idleMainLooper() verify(authCallback).onFailure(authExceptionCaptor.capture()) val error = authExceptionCaptor.firstValue @@ -1599,7 +1611,6 @@ public class WebAuthProviderTest { error.cause?.message, `is`("Could not find a public key for kid \"key123\"") ) - mockAPI.shutdown() } @Test @@ -1668,16 +1679,17 @@ public class WebAuthProviderTest { mockAPI.shutdown() } + @Test @Throws(Exception::class) public fun shouldFailToResumeLoginWhenKeyIdIsMissingFromIdTokenHeader() { val pkce = Mockito.mock(PKCE::class.java) `when`(pkce.codeChallenge).thenReturn("challenge") - val mockAPI = AuthenticationAPIMockServer() - mockAPI.willReturnValidJsonWebKeys() + val networkingClient: NetworkingClient = Mockito.mock(NetworkingClient::class.java) val authCallback = mock>() - val proxyAccount: Auth0 = Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, mockAPI.domain) - proxyAccount.networkingClient = SSLTestUtils.testClient + val proxyAccount = + Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, JwtTestUtils.EXPECTED_BASE_DOMAIN) + proxyAccount.networkingClient = networkingClient login(proxyAccount) .withState("1234567890") .withNonce("abcdefg") @@ -1709,12 +1721,18 @@ public class WebAuthProviderTest { Date(), "codeScope" ) + val emptyJwksJson = """{"keys": []}""" + val jwksInputStream: InputStream = ByteArrayInputStream(emptyJwksJson.toByteArray()) + val jwksResponse = ServerResponse(200, jwksInputStream, emptyMap()) + Mockito.doReturn(jwksResponse).`when`(networkingClient).load( + eq(proxyAccount.getDomainUrl() + ".well-known/jwks.json"), + any() + ) Mockito.doAnswer { callbackCaptor.firstValue.onSuccess(codeCredentials) null }.`when`(pkce).getToken(eq("1234"), callbackCaptor.capture()) Assert.assertTrue(resume(intent)) - mockAPI.takeRequest() ShadowLooper.idleMainLooper() verify(authCallback).onFailure(authExceptionCaptor.capture()) val error = authExceptionCaptor.firstValue @@ -1730,7 +1748,6 @@ public class WebAuthProviderTest { error.cause?.message, `is`("Could not find a public key for kid \"null\"") ) - mockAPI.shutdown() } @Test @@ -2733,21 +2750,13 @@ public class WebAuthProviderTest { //DPoP - public fun shouldReturnSameInstanceWhenCallingUseDPoPMultipleTimes() { - val provider1 = WebAuthProvider.useDPoP(mockContext) - val provider2 = WebAuthProvider.useDPoP(mockContext) - - assertThat(provider1, `is`(provider2)) - assertThat(WebAuthProvider.useDPoP(mockContext), `is`(provider1)) - } - @Test public fun shouldPassDPoPInstanceToOAuthManagerWhenDPoPIsEnabled() { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) val managerInstance = WebAuthProvider.managerInstance as OAuthManager @@ -2767,8 +2776,8 @@ public class WebAuthProviderTest { public fun shouldGenerateKeyPairWhenDPoPIsEnabledAndNoKeyPairExists() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(mockKeyStore).generateKeyPair(any(), any()) @@ -2779,8 +2788,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(mockKeyStore, never()).generateKeyPair(any(), any()) @@ -2801,8 +2810,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -2821,8 +2830,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(null) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) verify(activity).startActivity(intentCaptor.capture()) @@ -2837,8 +2846,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(true) `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) - val builder = WebAuthProvider.useDPoP(mockContext) - .login(account) + val builder = login(account) + .useDPoP(mockContext) .withConnection("test-connection") builder.start(activity, callback) @@ -2851,28 +2860,14 @@ public class WebAuthProviderTest { assertThat(uri, UriMatchers.hasParamWithName("dpop_jkt")) } - @Test - public fun shouldNotAffectLogoutWhenDPoPIsEnabled() { - WebAuthProvider.useDPoP(mockContext) - .logout(account) - .start(activity, voidCallback) - - verify(activity).startActivity(intentCaptor.capture()) - val uri = - intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) - assertThat(uri, `is`(notNullValue())) - // Logout should not have DPoP parameters - assertThat(uri, not(UriMatchers.hasParamWithName("dpop_jkt"))) - } - @Test public fun shouldHandleDPoPKeyGenerationFailureGracefully() { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) doThrow(DPoPException.KEY_GENERATION_ERROR) .`when`(mockKeyStore).generateKeyPair(any(), any()) - WebAuthProvider.useDPoP(mockContext) - .login(account) + login(account) + .useDPoP(mockContext) .start(activity, callback) // Verify that the authentication fails when DPoP key generation fails @@ -2886,6 +2881,40 @@ public class WebAuthProviderTest { verify(activity, never()).startActivity(any()) } + @Test + public fun shouldNotApplyDPoPToSubsequentLoginCallsWhenNotExplicitlyEnabled() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + // First login with DPoP enabled + login(account) + .useDPoP(mockContext) + .withScope("openid profile") + .start(activity, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val firstUri = + intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(firstUri, `is`(notNullValue())) + assertThat(firstUri, UriMatchers.hasParamWithName("dpop_jkt")) + assertThat(firstUri?.getQueryParameter("scope"), `is`("openid profile")) + + // Reset the manager instance and captor for the second call + WebAuthProvider.resetManagerInstance() + + login(account) + .withScope("openid email") + .start(activity, callback) + + // Verify second startActivity call + verify(activity, times(2)).startActivity(intentCaptor.capture()) + val secondUri = + intentCaptor.lastValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + assertThat(secondUri, `is`(notNullValue())) + assertThat(secondUri?.getQueryParameter("scope"), `is`("openid email")) + assertThat(secondUri, not(UriMatchers.hasParamWithName("dpop_jkt"))) + } + @Test @Throws(Exception::class) public fun shouldResumeLoginSuccessfullyWithDPoPEnabled() { @@ -2901,8 +2930,8 @@ public class WebAuthProviderTest { val proxyAccount: Auth0 = Auth0.getInstance(JwtTestUtils.EXPECTED_AUDIENCE, mockAPI.domain) proxyAccount.networkingClient = SSLTestUtils.testClient - WebAuthProvider.useDPoP(mockContext) - .login(proxyAccount) + login(proxyAccount) + .useDPoP(mockContext) .withPKCE(pkce) .start(activity, authCallback) @@ -3018,8 +3047,515 @@ public class WebAuthProviderTest { return if (hash.length == 1) "" else hash } + @Test + public fun shouldStartLoginWithEphemeralBrowsing() { + val options = Mockito.mock(CustomTabsOptions::class.java) + val ephemeralOptions = Mockito.mock(CustomTabsOptions::class.java) + `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true) + `when`(options.copyWithEphemeralBrowsing()).thenReturn(ephemeralOptions) + login(account) + .withCustomTabsOptions(options) + .withEphemeralBrowsing() + .start(activity, callback) + verify(options).copyWithEphemeralBrowsing() + } + + @Test + public fun shouldNotSetEphemeralBrowsingByDefault() { + val options = Mockito.mock(CustomTabsOptions::class.java) + `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true) + login(account) + .withCustomTabsOptions(options) + .start(activity, callback) + verify(options, Mockito.never()).copyWithEphemeralBrowsing() + } + + @Test + public fun shouldStartLoginWithAuthTab() { + val options = Mockito.mock(CustomTabsOptions::class.java) + val authTabOptions = Mockito.mock(CustomTabsOptions::class.java) + `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true) + `when`(options.copyWithAuthTab()).thenReturn(authTabOptions) + login(account) + .withCustomTabsOptions(options) + .withAuthTab() + .start(activity, callback) + verify(options).copyWithAuthTab() + } + + @Test + public fun shouldNotSetAuthTabByDefault() { + val options = Mockito.mock(CustomTabsOptions::class.java) + `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true) + login(account) + .withCustomTabsOptions(options) + .start(activity, callback) + verify(options, Mockito.never()).copyWithAuthTab() + } + + @Test + public fun shouldStartLogoutWithAuthTab() { + val options = Mockito.mock(CustomTabsOptions::class.java) + val authTabOptions = Mockito.mock(CustomTabsOptions::class.java) + `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true) + `when`(options.copyWithAuthTab()).thenReturn(authTabOptions) + `when`(authTabOptions.hasCompatibleBrowser(activity.packageManager)).thenReturn(true) + logout(account) + .withCustomTabsOptions(options) + .withAuthTab() + .start(activity, voidCallback) + verify(options).copyWithAuthTab() + } + + @Test + public fun shouldNotSetAuthTabByDefaultOnLogout() { + val options = Mockito.mock(CustomTabsOptions::class.java) + `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true) + logout(account) + .withCustomTabsOptions(options) + .start(activity, voidCallback) + verify(options, Mockito.never()).copyWithAuthTab() + } + + // --- LifecycleAwareCallback tests --- + + @Test + public fun shouldInvokeOnDetachedWithLoginResultAfterDestroy() { + val credentials = Mockito.mock(Credentials::class.java) + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, error -> + capturedSuccess = success + capturedError = error + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(credentials) + + Assert.assertEquals(credentials, capturedSuccess) + Assert.assertNull(capturedError) + verify(callback, Mockito.never()).onSuccess(any()) + } + + @Test + public fun shouldInvokeOnDetachedWithLoginFailureAfterDestroy() { + val error = AuthenticationException("canceled", "User canceled") + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, detachedError -> + capturedSuccess = success + capturedError = detachedError + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + Assert.assertNull(capturedSuccess) + Assert.assertEquals(error, capturedError) + verify(callback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldDeliverDirectlyWhenLifecycleCallbackIsAlive() { + val credentials = Mockito.mock(Credentials::class.java) + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onSuccess(credentials) + + verify(callback).onSuccess(credentials) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldDeliverFailureDirectlyWhenLifecycleCallbackIsAlive() { + val error = AuthenticationException("canceled", "User canceled") + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onFailure(error) + + verify(callback).onFailure(error) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldInvokeOnDetachedWithLogoutSuccessAfterDestroy() { + var onDetachedCalled = false + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, error -> + onDetachedCalled = true + capturedError = error + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(null) + + Assert.assertTrue(onDetachedCalled) + Assert.assertNull(capturedError) + verify(voidCallback, Mockito.never()).onSuccess(any()) + } + + @Test + public fun shouldInvokeOnDetachedWithLogoutFailureAfterDestroy() { + val error = AuthenticationException("canceled", "User closed the browser") + var onDetachedCalled = false + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, detachedError -> + onDetachedCalled = true + capturedError = detachedError + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + Assert.assertTrue(onDetachedCalled) + Assert.assertEquals(error, capturedError) + verify(voidCallback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldDeliverLogoutDirectlyWhenLifecycleCallbackIsAlive() { + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onSuccess(null) + + verify(voidCallback).onSuccess(null) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldRegisterAsLifecycleObserverOnInit() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + verify(lifecycle).addObserver(lifecycleCallback) + } + + @Test + public fun shouldUnregisterLifecycleObserverOnDestroy() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + + verify(lifecycle).removeObserver(lifecycleCallback) + } + + + @Test + public fun shouldWrapCallbackWithLifecycleAwareCallbackWhenStartedWithLifecycleOwner() { + val lifecycle = Mockito.mock(Lifecycle::class.java) + val lifecycleActivity = Mockito.mock(TestLifecycleOwnerActivity::class.java) + Mockito.`when`(lifecycleActivity.lifecycle).thenReturn(lifecycle) + Mockito.`when`(lifecycleActivity.applicationContext).thenReturn(lifecycleActivity) + Mockito.`when`(lifecycleActivity.packageName).thenReturn("com.auth0.test") + Mockito.doReturn(false).`when`(lifecycleActivity).bindService(any(), any(), ArgumentMatchers.anyInt()) + BrowserPickerTest.setupBrowserContext(lifecycleActivity, listOf("com.auth0.browser"), null, null) + + login(account).start(lifecycleActivity, callback) + + verify(lifecycle).addObserver(any>()) + } + + + @Test + public fun shouldRegisterLifecycleObserverOnRegisterCallbacks() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + + verify(lifecycle).addObserver(isA()) + } + + @Test + public fun shouldRemoveLifecycleObserverOnDestroyAfterRegisterCallbacks() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onDestroy(lifecycleOwner) + + verify(lifecycle).removeObserver(observerCaptor.firstValue) + } + + @Test + public fun shouldRemoveLoginCallbackFromCallbacksOnDestroy() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + Assert.assertEquals(1, WebAuthProvider.callbacksCount()) + + observerCaptor.firstValue.onDestroy(lifecycleOwner) + + Assert.assertEquals(0, WebAuthProvider.callbacksCount()) + } + + + @Test + public fun shouldDeliverPendingLoginResultOnResume() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.setPendingLoginResult(credentials) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(callback).onSuccess(credentials) + Assert.assertFalse(WebAuthProvider.hasPendingLoginResult()) + } + + @Test + public fun shouldDeliverPendingLogoutResultOnResume() { + WebAuthProvider.setPendingLogoutResult() + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(voidCallback).onSuccess(null) + Assert.assertFalse(WebAuthProvider.hasPendingLogoutResult()) + } + + @Test + public fun shouldNotDeliverLoginResultOnResumeWhenNoPendingResult() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + verify(callback, Mockito.never()).onSuccess(any()) + verify(callback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldNotDeliverLogoutResultOnResumeWhenNoPendingResult() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + verify(voidCallback, Mockito.never()).onSuccess(any()) + verify(voidCallback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldClearPendingLoginResultOnNewLoginStart() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.setPendingLoginResult(credentials) + Assert.assertTrue(WebAuthProvider.hasPendingLoginResult()) + + login(account).start(activity, callback) + + Assert.assertFalse(WebAuthProvider.hasPendingLoginResult()) + } + + @Test + public fun shouldClearPendingLogoutResultOnNewLogoutStart() { + WebAuthProvider.setPendingLogoutResult() + Assert.assertTrue(WebAuthProvider.hasPendingLogoutResult()) + + logout(account).start(activity, voidCallback) + + Assert.assertFalse(WebAuthProvider.hasPendingLogoutResult()) + } + + @Test + public fun shouldAuthorizeWithRequestUri() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + Assert.assertNotNull(WebAuthProvider.managerInstance) + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(JwtTestUtils.EXPECTED_AUDIENCE)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(requestUri)) + } + + @Test + public fun shouldAuthorizeWithRequestUriAndSessionTransferToken() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val sessionTransferToken = "stt_test_token_value" + val parCallback: Callback = mock() + + WebAuthProvider.authorizeWithRequestUri(account) + .withSessionTransferToken(sessionTransferToken) + .start(activity, requestUri, parCallback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(JwtTestUtils.EXPECTED_AUDIENCE)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(requestUri)) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(sessionTransferToken)) + } + + @Test + public fun shouldFailAuthorizeWithRequestUriWhenInvalidRequestUri() { + val parCallback: Callback = mock() + val exceptionCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, "invalid-uri", parCallback) + + verify(parCallback).onFailure(exceptionCaptor.capture()) + assertThat(exceptionCaptor.firstValue.getCode(), `is`("a0.invalid_request_uri")) + } + + @Test + public fun shouldFailAuthorizeWithRequestUriWhenNoBrowserAvailable() { + BrowserPickerTest.setupBrowserContext(activity, emptyList(), null, null) + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + val exceptionCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + verify(parCallback).onFailure(exceptionCaptor.capture()) + assertThat(exceptionCaptor.firstValue.isBrowserAppNotAvailable, `is`(true)) + } + + @Test + public fun shouldResumeAuthorizeWithRequestUriWithCode() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + val codeCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = Intent().apply { + data = Uri.parse("https://${JwtTestUtils.EXPECTED_BASE_DOMAIN}/android/com.auth0.test/callback?code=test-code&state=test-state") + } + Assert.assertTrue(resume(intent)) + + verify(parCallback).onSuccess(codeCaptor.capture()) + assertThat(codeCaptor.firstValue.code, `is`("test-code")) + assertThat(codeCaptor.firstValue.state, `is`("test-state")) + } + private companion object { private const val KEY_STATE = "state" private const val KEY_NONCE = "nonce" } -} \ No newline at end of file +} + +internal abstract class TestLifecycleOwnerActivity : Activity(), LifecycleOwner \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt b/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt index 7a71a70bf..627a68641 100644 --- a/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/DefaultClientTest.kt @@ -11,7 +11,6 @@ import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.CoreMatchers.* import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.empty import org.hamcrest.Matchers.hasSize import org.hamcrest.collection.IsMapContaining.hasEntry import org.hamcrest.collection.IsMapWithSize.anEmptyMap @@ -81,6 +80,7 @@ public class DefaultClientTest { } @Test + @Suppress("DEPRECATION") public fun shouldHaveLoggingDisabledByDefault() { val netClient = DefaultClient(enableLogging = false) assertThat(netClient.okHttpClient.interceptors, hasSize(1)) @@ -91,6 +91,7 @@ public class DefaultClientTest { } @Test + @Suppress("DEPRECATION") public fun shouldHaveRetryInterceptorEnabled() { val netClient = DefaultClient(enableLogging = false) assertThat(netClient.okHttpClient.interceptors, hasSize(1)) @@ -101,6 +102,7 @@ public class DefaultClientTest { } @Test + @Suppress("DEPRECATION") public fun shouldHaveLoggingEnabledIfSpecified() { val netClient = DefaultClient(enableLogging = true) assertThat(netClient.okHttpClient.interceptors, hasSize(2)) @@ -113,6 +115,7 @@ public class DefaultClientTest { } @Test + @Suppress("DEPRECATION") public fun shouldHaveDefaultTimeoutValues() { val client = DefaultClient() assertThat(client.okHttpClient.connectTimeoutMillis, equalTo(10 * 1000)) @@ -120,6 +123,7 @@ public class DefaultClientTest { } @Test + @Suppress("DEPRECATION") public fun shouldUseTimeoutConfigIfSpecified() { val client = DefaultClient(connectTimeout = 100, readTimeout = 200) assertThat(client.okHttpClient.connectTimeoutMillis, equalTo(100 * 1000)) @@ -353,14 +357,185 @@ public class DefaultClientTest { } private fun createDefaultClientForTest(defaultHeaders: Map): DefaultClient { - return DefaultClient( - defaultHeaders = defaultHeaders, - readTimeout = 10, - connectTimeout = 10, - enableLogging = false, - gson = gson, - sslSocketFactory = SSLTestUtils.clientCertificates.sslSocketFactory(), - trustManager = SSLTestUtils.clientCertificates.trustManager + return DefaultClient.Builder() + .connectTimeout(10) + .readTimeout(10) + .defaultHeaders(defaultHeaders) + .enableLogging(false) + .gson(gson) + .sslSocketFactory( + SSLTestUtils.clientCertificates.sslSocketFactory(), + SSLTestUtils.clientCertificates.trustManager + ) + .build() + } + + @Test + public fun builderShouldCreateClientWithDefaultValues() { + val client = DefaultClient.Builder().build() + assertThat(client.okHttpClient.connectTimeoutMillis, equalTo(10 * 1000)) + assertThat(client.okHttpClient.readTimeoutMillis, equalTo(10 * 1000)) + assertThat(client.okHttpClient.writeTimeoutMillis, equalTo(10 * 1000)) + assertThat(client.okHttpClient.callTimeoutMillis, equalTo(0)) + assertThat(client.okHttpClient.interceptors, hasSize(1)) + assertThat(client.okHttpClient.interceptors[0] is RetryInterceptor, equalTo(true)) + } + + @Test + public fun builderShouldSetConnectTimeout() { + val client = DefaultClient.Builder() + .connectTimeout(30) + .build() + assertThat(client.okHttpClient.connectTimeoutMillis, equalTo(30 * 1000)) + } + + @Test + public fun builderShouldSetReadTimeout() { + val client = DefaultClient.Builder() + .readTimeout(45) + .build() + assertThat(client.okHttpClient.readTimeoutMillis, equalTo(45 * 1000)) + } + + @Test + public fun builderShouldSetWriteTimeout() { + val client = DefaultClient.Builder() + .writeTimeout(20) + .build() + assertThat(client.okHttpClient.writeTimeoutMillis, equalTo(20 * 1000)) + } + + @Test + public fun builderShouldSetCallTimeout() { + val client = DefaultClient.Builder() + .callTimeout(60) + .build() + assertThat(client.okHttpClient.callTimeoutMillis, equalTo(60 * 1000)) + } + + @Test + public fun builderShouldSetAllTimeouts() { + val client = DefaultClient.Builder() + .connectTimeout(15) + .readTimeout(25) + .writeTimeout(35) + .callTimeout(120) + .build() + assertThat(client.okHttpClient.connectTimeoutMillis, equalTo(15 * 1000)) + assertThat(client.okHttpClient.readTimeoutMillis, equalTo(25 * 1000)) + assertThat(client.okHttpClient.writeTimeoutMillis, equalTo(35 * 1000)) + assertThat(client.okHttpClient.callTimeoutMillis, equalTo(120 * 1000)) + } + + @Test + public fun builderShouldEnableLoggingWithDefaultLevel() { + val client = DefaultClient.Builder() + .enableLogging(true) + .build() + assertThat(client.okHttpClient.interceptors, hasSize(2)) + val loggingInterceptor = client.okHttpClient.interceptors[1] as HttpLoggingInterceptor + assertThat(loggingInterceptor.level, equalTo(HttpLoggingInterceptor.Level.BODY)) + } + + @Test + public fun builderShouldNotAddLoggingInterceptorWhenDisabled() { + val client = DefaultClient.Builder() + .enableLogging(false) + .build() + assertThat(client.okHttpClient.interceptors, hasSize(1)) + assertThat(client.okHttpClient.interceptors[0] is RetryInterceptor, equalTo(true)) + } + + @Test + public fun builderShouldSetCustomLogger() { + val logs = mutableListOf() + val customLogger = HttpLoggingInterceptor.Logger { message -> logs.add(message) } + + val client = DefaultClient.Builder() + .enableLogging(true) + .logger(customLogger) + .sslSocketFactory( + SSLTestUtils.clientCertificates.sslSocketFactory(), + SSLTestUtils.clientCertificates.trustManager + ) + .build() + + assertThat(client.okHttpClient.interceptors, hasSize(2)) + assertThat(client.okHttpClient.interceptors[1] is HttpLoggingInterceptor, equalTo(true)) + + enqueueMockResponse(STATUS_SUCCESS, JSON_OK) + executeRequest(HttpMethod.GET, client) + assertThat(logs.isEmpty(), equalTo(false)) + } + + @Test + public fun builderShouldSetDefaultHeaders() { + enqueueMockResponse(STATUS_SUCCESS, JSON_OK) + val client = DefaultClient.Builder() + .defaultHeaders(mapOf("x-custom" to "test-value")) + .sslSocketFactory( + SSLTestUtils.clientCertificates.sslSocketFactory(), + SSLTestUtils.clientCertificates.trustManager + ) + .build() + + executeRequest(HttpMethod.GET, client) + val sentRequest = mockServer.takeRequest() + requestAssertions(sentRequest, HttpMethod.GET, mapOf("x-custom" to "test-value")) + } + + @Test + public fun builderNonRetryableClientShouldInheritConfiguration() { + val client = DefaultClient.Builder() + .connectTimeout(25) + .readTimeout(35) + .writeTimeout(45) + .enableLogging(true) + .build() + + assertThat( + client.nonRetryableOkHttpClient.connectTimeoutMillis, + equalTo(client.okHttpClient.connectTimeoutMillis) + ) + assertThat( + client.nonRetryableOkHttpClient.readTimeoutMillis, + equalTo(client.okHttpClient.readTimeoutMillis) + ) + assertThat( + client.nonRetryableOkHttpClient.writeTimeoutMillis, + equalTo(client.okHttpClient.writeTimeoutMillis) + ) + assertThat( + client.nonRetryableOkHttpClient.interceptors.size, + equalTo(client.okHttpClient.interceptors.size) + ) + assertThat(client.okHttpClient.retryOnConnectionFailure, equalTo(true)) + assertThat(client.nonRetryableOkHttpClient.retryOnConnectionFailure, equalTo(false)) + } + + @Test + @Suppress("DEPRECATION") + public fun legacyConstructorShouldStillWork() { + val client = DefaultClient() + assertThat(client.okHttpClient.connectTimeoutMillis, equalTo(10 * 1000)) + assertThat(client.okHttpClient.readTimeoutMillis, equalTo(10 * 1000)) + assertThat(client.okHttpClient.interceptors, hasSize(1)) + assertThat(client.okHttpClient.interceptors[0] is RetryInterceptor, equalTo(true)) + } + + @Test + @Suppress("DEPRECATION") + public fun legacyConstructorWithParamsShouldStillWork() { + val client = DefaultClient( + connectTimeout = 30, + readTimeout = 45, + defaultHeaders = mapOf("X-Test" to "value"), + enableLogging = true ) + assertThat(client.okHttpClient.connectTimeoutMillis, equalTo(30 * 1000)) + assertThat(client.okHttpClient.readTimeoutMillis, equalTo(45 * 1000)) + assertThat(client.okHttpClient.interceptors, hasSize(2)) + assertThat(client.okHttpClient.interceptors[0] is RetryInterceptor, equalTo(true)) + assertThat(client.okHttpClient.interceptors[1] is HttpLoggingInterceptor, equalTo(true)) } } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt b/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt index bb10f1ce5..687f3847b 100644 --- a/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/RetryInterceptorTest.kt @@ -5,12 +5,12 @@ import com.auth0.android.dpop.DPoPKeyStore import com.auth0.android.dpop.DPoPUtil import com.auth0.android.dpop.FakeECPrivateKey import com.auth0.android.dpop.FakeECPublicKey -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import okhttp3.Interceptor import okhttp3.Protocol import okhttp3.Request diff --git a/auth0/src/test/java/com/auth0/android/request/internal/BaseAuthenticationRequestTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/BaseAuthenticationRequestTest.kt index 76c9d7990..157a6d5a4 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/BaseAuthenticationRequestTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/BaseAuthenticationRequestTest.kt @@ -3,7 +3,7 @@ package com.auth0.android.request.internal import com.auth0.android.authentication.AuthenticationException import com.auth0.android.request.* import com.auth0.android.result.Credentials -import com.nhaarman.mockitokotlin2.* +import org.mockito.kotlin.* import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert import org.hamcrest.collection.IsMapContaining diff --git a/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt index c459068f9..24f608345 100755 --- a/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt @@ -8,7 +8,7 @@ import com.auth0.android.dpop.DPoPUtil.DPOP_HEADER import com.auth0.android.request.* import com.google.gson.Gson import com.google.gson.JsonIOException -import com.nhaarman.mockitokotlin2.* +import org.mockito.kotlin.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -199,7 +199,7 @@ public class BaseRequestTest { MatcherAssert.assertThat(exception, Matchers.`is`(wrappingAuth0Exception)) MatcherAssert.assertThat(result, Matchers.`is`(Matchers.nullValue())) verify(errorAdapter).fromException(networkError) - verifyZeroInteractions(resultAdapter) + verifyNoMoreInteractions(resultAdapter) verifyNoMoreInteractions(errorAdapter) } @@ -253,7 +253,7 @@ public class BaseRequestTest { eq(422), any() ) MatcherAssert.assertThat(wasResponseStreamClosed, Matchers.`is`(true)) - verifyZeroInteractions(resultAdapter) + verifyNoMoreInteractions(resultAdapter) verifyNoMoreInteractions(errorAdapter) } @@ -287,7 +287,7 @@ public class BaseRequestTest { ) ) MatcherAssert.assertThat(wasResponseStreamClosed, Matchers.`is`(true)) - verifyZeroInteractions(resultAdapter) + verifyNoMoreInteractions(resultAdapter) verifyNoMoreInteractions(errorAdapter) } @@ -308,7 +308,7 @@ public class BaseRequestTest { verify(resultAdapter).fromJson(any(), any()) MatcherAssert.assertThat(wasResponseStreamClosed, Matchers.`is`(true)) verifyNoMoreInteractions(resultAdapter) - verifyZeroInteractions(errorAdapter) + verifyNoMoreInteractions(errorAdapter) } @Test @@ -328,7 +328,7 @@ public class BaseRequestTest { verify(errorAdapter).fromJsonResponse(eq(422), any()) verify(errorAdapter).fromException(networkError) MatcherAssert.assertThat(wasResponseStreamClosed, Matchers.`is`(true)) - verifyZeroInteractions(resultAdapter) + verifyNoMoreInteractions(resultAdapter) verifyNoMoreInteractions(errorAdapter) } @@ -348,7 +348,7 @@ public class BaseRequestTest { MatcherAssert.assertThat(exception, Matchers.`is`(wrappingAuth0Exception)) verify(errorAdapter).fromException(networkError) MatcherAssert.assertThat(wasResponseStreamClosed, Matchers.`is`(true)) - verifyZeroInteractions(resultAdapter) + verifyNoMoreInteractions(resultAdapter) verifyNoMoreInteractions(errorAdapter) } diff --git a/auth0/src/test/java/com/auth0/android/request/internal/CommonThreadSwitcherDelegateTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/CommonThreadSwitcherDelegateTest.kt index c29eccf15..cd7c1eb55 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/CommonThreadSwitcherDelegateTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/CommonThreadSwitcherDelegateTest.kt @@ -5,7 +5,7 @@ import com.auth0.android.callback.Callback import com.auth0.android.request.* import com.auth0.android.util.CommonThreadSwitcherRule import com.google.gson.Gson -import com.nhaarman.mockitokotlin2.* +import org.mockito.kotlin.* import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.junit.Before diff --git a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt index 17e768a5e..d9a6ffcb0 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt @@ -1,5 +1,6 @@ package com.auth0.android.request.internal +import android.util.Base64 import androidx.test.espresso.matcher.ViewMatchers.assertThat import com.google.gson.stream.MalformedJsonException import org.hamcrest.Matchers.* @@ -203,4 +204,63 @@ public class JwtTest { assertThat(jwt.issuedAt, `is`(nullValue())) } + + // IPSIE session_expiry claim + + @Test + public fun shouldGetSessionExpiryAsLongSeconds() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000}""")) + assertThat(jwt, `is`(notNullValue())) + assertThat(jwt.sessionExpiry, `is`(1700000000L)) + } + + @Test + public fun shouldGetNullSessionExpiryIfMissing() { + val jwt = Jwt("eyJhbGciOiJIUzI1NiJ9.e30.something") + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + + @Test + public fun shouldGetNullSessionExpiryIfNonNumeric() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":"not-a-number"}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + + @Test + public fun shouldTruncateFractionalSessionExpiryToLong() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000.75}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(1700000000L)) + } + + @Test + public fun shouldGetNullSessionExpiryIfImplausiblyLarge() { + // A value mistakenly emitted in milliseconds (1700000000000) is ~50,000 years out in seconds + // and would silently disable the ceiling; it must be treated as "no ceiling" (null) instead. + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000000}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + + /** + * Builds a JWT with a fixed `alg=HS256` header and a dummy signature, encoding the given JSON + * payload so that [Jwt] can decode it. The signature is never verified by [Jwt]. + */ + private fun jwtWithPayload(jsonPayload: String): String { + val header = encode("""{"alg":"HS256","typ":"JWT"}""") + val payload = encode(jsonPayload) + return "$header.$payload.signature" + } + + private fun encode(json: String): String = + Base64.encodeToString( + json.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerMock.kt b/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerMock.kt new file mode 100644 index 000000000..48e4f9612 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerMock.kt @@ -0,0 +1,24 @@ +package com.auth0.android.request.internal + +import com.auth0.android.result.CredentialsMock +import com.auth0.android.result.SSOCredentials +import com.auth0.android.result.SSOCredentialsMock +import java.util.* + +internal class SSOCredentialsDeserializerMock : SSOCredentialsDeserializer() { + override fun createSSOCredentials( + sessionTransferToken: String, + idToken: String, + issuedTokenType: String, + tokenType: String, + expiresAt: Date, + refreshToken: String? + ): SSOCredentials { + return SSOCredentialsMock.create( + sessionTransferToken, idToken, issuedTokenType, tokenType, refreshToken, expiresAt + ) + } + + override val currentTimeInMillis: Long + get() = CredentialsMock.CURRENT_TIME_MS +} diff --git a/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerTest.kt new file mode 100644 index 000000000..31a5aa5b1 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerTest.kt @@ -0,0 +1,90 @@ +package com.auth0.android.request.internal + +import com.auth0.android.result.CredentialsMock +import com.auth0.android.result.SSOCredentials +import com.google.gson.Gson +import org.hamcrest.CoreMatchers +import org.hamcrest.MatcherAssert +import org.hamcrest.Matchers +import org.hamcrest.core.Is +import org.junit.Before +import org.junit.Test + +public class SSOCredentialsDeserializerTest { + private lateinit var gson: Gson + + @Before + public fun setUp() { + val deserializer = SSOCredentialsDeserializerMock() + gson = GsonProvider.gson.newBuilder() + .registerTypeAdapter(SSOCredentials::class.java, deserializer) + .create() + } + + @Test + @Throws(Exception::class) + public fun shouldSetExpiresInFromExpiresInSeconds() { + val json = generateSSOCredentialsJSON() + val credentials = gson.getAdapter(SSOCredentials::class.java).fromJson(json) + MatcherAssert.assertThat(credentials.expiresAt, Is.`is`(CoreMatchers.notNullValue())) + val expiresAt = credentials.expiresAt.time.toDouble() + val expectedExpiresAt = (CredentialsMock.CURRENT_TIME_MS + 300 * 1000).toDouble() + MatcherAssert.assertThat(expiresAt, Is.`is`(Matchers.closeTo(expectedExpiresAt, 1.0))) + } + + @Test + @Throws(Exception::class) + public fun shouldDeserializeAllFields() { + val json = generateSSOCredentialsJSON() + val credentials = gson.getAdapter(SSOCredentials::class.java).fromJson(json) + MatcherAssert.assertThat( + credentials.sessionTransferToken, + Is.`is`("session-transfer-token") + ) + MatcherAssert.assertThat(credentials.idToken, Is.`is`("id-token-value")) + MatcherAssert.assertThat( + credentials.issuedTokenType, + Is.`is`("urn:auth0:params:oauth:token-type:session-transfer-token") + ) + MatcherAssert.assertThat(credentials.tokenType, Is.`is`("N_A")) + MatcherAssert.assertThat(credentials.refreshToken, Is.`is`("refresh-token-value")) + } + + @Test + @Throws(Exception::class) + public fun shouldDeserializeWithNullRefreshToken() { + val json = generateSSOCredentialsJSONWithoutRefreshToken() + val credentials = gson.getAdapter(SSOCredentials::class.java).fromJson(json) + MatcherAssert.assertThat( + credentials.sessionTransferToken, + Is.`is`("session-transfer-token") + ) + MatcherAssert.assertThat(credentials.idToken, Is.`is`("id-token-value")) + MatcherAssert.assertThat(credentials.refreshToken, Is.`is`(CoreMatchers.nullValue())) + } + + private fun generateSSOCredentialsJSON(): String { + return """ + { + "access_token": "session-transfer-token", + "id_token": "id-token-value", + "issued_token_type": "urn:auth0:params:oauth:token-type:session-transfer-token", + "token_type": "N_A", + "expires_in": 300, + "refresh_token": "refresh-token-value" + } + """.trimIndent() + } + + private fun generateSSOCredentialsJSONWithoutRefreshToken(): String { + return """ + { + "access_token": "session-transfer-token", + "id_token": "id-token-value", + "issued_token_type": "urn:auth0:params:oauth:token-type:session-transfer-token", + "token_type": "N_A", + "expires_in": 300 + } + """.trimIndent() + } +} diff --git a/auth0/src/test/java/com/auth0/android/request/internal/TLS12SocketFactoryTest.java b/auth0/src/test/java/com/auth0/android/request/internal/TLS12SocketFactoryTest.java index 8391bebd2..6e773ba5c 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/TLS12SocketFactoryTest.java +++ b/auth0/src/test/java/com/auth0/android/request/internal/TLS12SocketFactoryTest.java @@ -17,10 +17,10 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Matchers.anyBoolean; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/auth0/src/test/java/com/auth0/android/request/internal/ThreadSwitcherShadow.java b/auth0/src/test/java/com/auth0/android/request/internal/ThreadSwitcherShadow.java index 0c096ba3f..17100bf9f 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/ThreadSwitcherShadow.java +++ b/auth0/src/test/java/com/auth0/android/request/internal/ThreadSwitcherShadow.java @@ -26,4 +26,9 @@ public ThreadSwitcherShadow() { public void backgroundThread(Runnable runnable) { executor.execute(runnable); } + + @Implementation + public void mainThread(Runnable runnable) { + executor.execute(runnable); + } } diff --git a/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt index 884ddf25f..262ed4b88 100755 --- a/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt @@ -11,10 +11,13 @@ import org.hamcrest.collection.IsMapWithSize import org.junit.Assert import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import java.io.StringReader import java.text.SimpleDateFormat import java.util.* +@RunWith(RobolectricTestRunner::class) public class UserProfileGsonTest : GsonBaseTest() { @Before @@ -345,6 +348,128 @@ public class UserProfileGsonTest : GsonBaseTest() { ) } + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithActorClaim() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456", + "type": "ai_agent", + "name": "My Agent" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.sub, `is`("agent-id-456")) + assertThat(userProfile.actor!!.extraProperties, hasEntry("type", "ai_agent" as Any)) + assertThat(userProfile.actor!!.extraProperties, hasEntry("name", "My Agent" as Any)) + assertThat(userProfile.actor!!.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNestedActorClaim() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456", + "act": { + "sub": "service-id-789", + "role": "intermediary" + } + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.sub, `is`("agent-id-456")) + assertThat(userProfile.actor!!.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.actor!!.sub, `is`("service-id-789")) + assertThat( + userProfile.actor!!.actor!!.extraProperties, + hasEntry("role", "intermediary" as Any) + ) + assertThat(userProfile.actor!!.actor!!.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenNotPresent() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User" +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenActIsNull() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": null +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenActHasNoSub() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "type": "ai_agent" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldNotIncludeActInExtraInfo() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.getExtraInfo(), not(hasKey("act"))) + } + private fun getUTCDate(year: Int, month: Int, day: Int, hr: Int, min: Int, sec: Int, ms: Int): Date { val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) cal[Calendar.YEAR] = year diff --git a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt index ac38dc36b..e8d6a272d 100644 --- a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt +++ b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt @@ -1,5 +1,6 @@ package com.auth0.android.result +import android.util.Base64 import com.auth0.android.request.internal.GsonProvider.gson import org.hamcrest.MatcherAssert import org.hamcrest.Matchers @@ -81,4 +82,63 @@ public class CredentialsTest { Matchers.`is`("Credentials(idToken='xxxxx', accessToken='xxxxx', type='type', refreshToken='xxxxx', expiresAt='$date', scope='scope')") ) } + + @Test + public fun shouldGetSessionExpiresAtFromIdToken() { + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1700000000L)) + } + + @Test + public fun shouldGetNullSessionExpiresAtWhenClaimMissing() { + val credentials = Credentials( + jwtWithPayload("""{"sub":"auth0|123456"}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(Matchers.nullValue())) + } + + @Test + public fun shouldGetNullSessionExpiresAtWhenIdTokenIsNotAValidJwt() { + val credentials = + CredentialsMock.create("not-a-jwt", "accessToken", "type", "refreshToken", Date(), "scope") + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(Matchers.nullValue())) + } + + @Test + public fun shouldPreferPinnedSessionExpiresAtOverIdTokenClaim() { + // The manager stamps the value pinned at login; it must take precedence over a (later) + // claim re-emitted on the current ID token so the public value matches what is enforced. + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + credentials.pinnedSessionExpiresAt = 1690000000L + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1690000000L)) + } + + @Test + public fun shouldFallBackToIdTokenClaimWhenNoPinnedSessionExpiresAt() { + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + // No pinned value (credentials not served by a manager) -> decode from the ID token. + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1700000000L)) + } + + private fun jwtWithPayload(jsonPayload: String): String { + val header = encode("""{"alg":"HS256","typ":"JWT"}""") + val payload = encode(jsonPayload) + return "$header.$payload.signature" + } + + private fun encode(json: String): String = + Base64.encodeToString( + json.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/result/SSOCredentialsMock.kt b/auth0/src/test/java/com/auth0/android/result/SSOCredentialsMock.kt index 203fc6424..918a91825 100644 --- a/auth0/src/test/java/com/auth0/android/result/SSOCredentialsMock.kt +++ b/auth0/src/test/java/com/auth0/android/result/SSOCredentialsMock.kt @@ -1,19 +1,21 @@ package com.auth0.android.result +import java.util.Date + public class SSOCredentialsMock { public companion object { public fun create( accessToken: String, - idToken:String , + idToken: String, issuedTokenType: String, type: String, refreshToken: String?, - expiresIn: Int + expiresAt: Date ): SSOCredentials { return SSOCredentials( - accessToken,idToken, issuedTokenType, type, expiresIn, refreshToken + accessToken, idToken, issuedTokenType, type, expiresAt, refreshToken ) } } diff --git a/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java b/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java index f764335cf..d0fbaa9ec 100644 --- a/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java +++ b/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java @@ -29,7 +29,7 @@ public void setUp() { extraInfo = Collections.emptyMap(); userMetadata = Collections.emptyMap(); appMetadata = Collections.emptyMap(); - userProfile = new UserProfile("id", "name", "nickname", "pictureUrl", "email", true, "familyName", createdAt, identities, extraInfo, userMetadata, appMetadata, "givenName"); + userProfile = new UserProfile("id", "name", "nickname", "pictureUrl", "email", true, "familyName", createdAt, identities, extraInfo, userMetadata, appMetadata, "givenName", null); } @Test @@ -40,13 +40,13 @@ public void getId() { @Test public void shouldReturnSubIfMissingId() { Map extraInfo = Collections.singletonMap("sub", "fromSub"); - UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, extraInfo, null, null, null); + UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, extraInfo, null, null, null, null); assertThat(userProfile.getId(), is("fromSub")); } @Test public void shouldGetNullIdIfMissing() { - UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, null, null, null, null); + UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, null, null, null, null, null); assertThat(userProfile.getId(), is(nullValue())); } diff --git a/auth0/src/test/java/com/auth0/android/util/Auth0UserAgentTest.java b/auth0/src/test/java/com/auth0/android/util/Auth0UserAgentTest.java index fc7413d97..23fde9ea9 100755 --- a/auth0/src/test/java/com/auth0/android/util/Auth0UserAgentTest.java +++ b/auth0/src/test/java/com/auth0/android/util/Auth0UserAgentTest.java @@ -26,19 +26,19 @@ public class Auth0UserAgentTest { //Testing Android version only for a few SDKs @Test - @Config(sdk = 21) - public void shouldAlwaysIncludeAndroidVersionAPI21() { + @Config(sdk = 28) + public void shouldAlwaysIncludeAndroidVersionAPI28() { Auth0UserAgent auth0UserAgent = new Auth0UserAgent("auth0-java", "1.2.3"); assertThat(auth0UserAgent.getEnvironment(), is(notNullValue())); - assertThat(auth0UserAgent.getEnvironment().get("android"), is("21")); + assertThat(auth0UserAgent.getEnvironment().get("android"), is("28")); } @Test - @Config(sdk = 23) - public void shouldAlwaysIncludeAndroidVersionAPI23() { + @Config(sdk = 30) + public void shouldAlwaysIncludeAndroidVersionAPI30() { Auth0UserAgent auth0UserAgent = new Auth0UserAgent("auth0-java", "1.2.3"); assertThat(auth0UserAgent.getEnvironment(), is(notNullValue())); - assertThat(auth0UserAgent.getEnvironment().get("android"), is("23")); + assertThat(auth0UserAgent.getEnvironment().get("android"), is("30")); } @Test @@ -98,7 +98,7 @@ public void shouldGetLibraryVersion() { } @Test - @Config(sdk = 23) + @Config(sdk = 28) public void shouldGenerateCompleteTelemetryBase64Value() { Gson gson = new Gson(); Type mapType = new TypeToken>() { @@ -106,18 +106,18 @@ public void shouldGenerateCompleteTelemetryBase64Value() { Auth0UserAgent auth0UserAgentComplete = new Auth0UserAgent("auth0-java", "1.0.0", "1.2.3"); String value = auth0UserAgentComplete.getValue(); - assertThat(value, is("eyJuYW1lIjoiYXV0aDAtamF2YSIsImVudiI6eyJhbmRyb2lkIjoiMjMiLCJhdXRoMC5hbmRyb2lkIjoiMS4yLjMifSwidmVyc2lvbiI6IjEuMC4wIn0=")); + assertThat(value, is(notNullValue())); String completeString = new String(Base64.decode(value, Base64.URL_SAFE | Base64.NO_WRAP), StandardCharsets.UTF_8); Map complete = gson.fromJson(completeString, mapType); assertThat((String) complete.get("name"), is("auth0-java")); assertThat((String) complete.get("version"), is("1.0.0")); Map completeEnv = (Map) complete.get("env"); assertThat((String) completeEnv.get("auth0.android"), is("1.2.3")); - assertThat((String) completeEnv.get("android"), is("23")); + assertThat((String) completeEnv.get("android"), is("28")); } @Test - @Config(sdk = 23) + @Config(sdk = 28) public void shouldGenerateBasicTelemetryBase64Value() { Gson gson = new Gson(); Type mapType = new TypeToken>() { @@ -125,13 +125,13 @@ public void shouldGenerateBasicTelemetryBase64Value() { Auth0UserAgent auth0UserAgentBasic = new Auth0UserAgent("auth0-python", "99.3.1"); String value = auth0UserAgentBasic.getValue(); - assertThat(value, is("eyJuYW1lIjoiYXV0aDAtcHl0aG9uIiwiZW52Ijp7ImFuZHJvaWQiOiIyMyJ9LCJ2ZXJzaW9uIjoiOTkuMy4xIn0=")); + assertThat(value, is(notNullValue())); String basicString = new String(Base64.decode(value, Base64.URL_SAFE | Base64.NO_WRAP), StandardCharsets.UTF_8); Map basic = gson.fromJson(basicString, mapType); assertThat((String) basic.get("name"), is("auth0-python")); assertThat((String) basic.get("version"), is("99.3.1")); Map basicEnv = (Map) basic.get("env"); assertThat(basicEnv.get("auth0.android"), is(nullValue())); - assertThat((String) basicEnv.get("android"), is("23")); + assertThat((String) basicEnv.get("android"), is("28")); } } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt index e08361e57..8c770ae5d 100755 --- a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt @@ -96,6 +96,19 @@ internal class AuthenticationAPIMockServer : APIMockServer() { return this } + fun willReturnSuccessfulSSOExchange(): AuthenticationAPIMockServer { + val json = """{ + "access_token": "$ACCESS_TOKEN", + "id_token": "$ID_TOKEN", + "issued_token_type": "urn:auth0:params:oauth:token-type:session-transfer-token", + "token_type": "N_A", + "expires_in": 86000, + "refresh_token": "$REFRESH_TOKEN" + }""" + server.enqueue(responseWithJSON(json, 200)) + return this + } + fun willReturnSuccessfulLoginWithRecoveryCode(): AuthenticationAPIMockServer { val json = """{ "refresh_token": "$REFRESH_TOKEN", diff --git a/auth0/src/test/java/com/auth0/android/util/ManagementCallbackMatcher.java b/auth0/src/test/java/com/auth0/android/util/ManagementCallbackMatcher.java deleted file mode 100755 index 2f5bf4c95..000000000 --- a/auth0/src/test/java/com/auth0/android/util/ManagementCallbackMatcher.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.auth0.android.util; - -import com.auth0.android.callback.ManagementCallback; -import com.auth0.android.management.ManagementException; -import com.google.gson.reflect.TypeToken; -import com.jayway.awaitility.core.ConditionTimeoutException; - -import org.hamcrest.BaseMatcher; -import org.hamcrest.Description; -import org.hamcrest.Matcher; - -import static com.jayway.awaitility.Awaitility.await; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.isA; -import static org.hamcrest.Matchers.notNullValue; -import static org.hamcrest.Matchers.nullValue; - -public class ManagementCallbackMatcher extends BaseMatcher> { - private final Matcher payloadMatcher; - private final Matcher errorMatcher; - - public ManagementCallbackMatcher(Matcher payloadMatcher, Matcher errorMatcher) { - this.payloadMatcher = payloadMatcher; - this.errorMatcher = errorMatcher; - } - - @Override - @SuppressWarnings("unchecked") - public boolean matches(Object item) { - MockManagementCallback callback = (MockManagementCallback) item; - try { - await().until(callback.payload(), payloadMatcher); - await().until(callback.error(), errorMatcher); - return true; - } catch (ConditionTimeoutException e) { - return false; - } - } - - @Override - public void describeTo(Description description) { - description - .appendText("successful method be called"); - } - - public static Matcher> hasPayloadOfType(Class tClazz) { - return new ManagementCallbackMatcher<>(isA(tClazz), is(nullValue(ManagementException.class))); - } - - public static Matcher> hasPayloadOfType(TypeToken typeToken) { - return new ManagementCallbackMatcher<>(TypeTokenMatcher.isA(typeToken), is(nullValue(ManagementException.class))); - } - - public static Matcher> hasPayload(T payload) { - return new ManagementCallbackMatcher<>(equalTo(payload), is(nullValue(ManagementException.class))); - } - - public static Matcher> hasNoPayloadOfType(Class tClazz) { - return new ManagementCallbackMatcher<>(is(nullValue(tClazz)), is(notNullValue(ManagementException.class))); - } - - public static Matcher> hasNoPayloadOfType(TypeToken typeToken) { - return new ManagementCallbackMatcher<>(TypeTokenMatcher.isA(typeToken), is(nullValue(ManagementException.class))); - } - - public static Matcher> hasNoError() { - return new ManagementCallbackMatcher<>(is(notNullValue(Void.class)), is(nullValue(ManagementException.class))); - } - - public static Matcher> hasError() { - return new ManagementCallbackMatcher<>(is(nullValue(Void.class)), is(notNullValue(ManagementException.class))); - } -} diff --git a/auth0/src/test/java/com/auth0/android/util/MockManagementCallback.java b/auth0/src/test/java/com/auth0/android/util/MockManagementCallback.java deleted file mode 100755 index 74002f3d2..000000000 --- a/auth0/src/test/java/com/auth0/android/util/MockManagementCallback.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.auth0.android.util; - -import androidx.annotation.NonNull; - -import com.auth0.android.callback.ManagementCallback; -import com.auth0.android.management.ManagementException; - -import java.util.concurrent.Callable; - -public class MockManagementCallback implements ManagementCallback { - - private ManagementException error; - private T payload; - - @Override - public void onFailure(@NonNull ManagementException error) { - this.error = error; - } - - @Override - public void onSuccess(@NonNull T result) { - this.payload = result; - } - - public Callable error() { - return () -> error; - } - - public Callable payload() { - return () -> payload; - } - - public ManagementException getError() { - return error; - } - - public T getPayload() { - return payload; - } -} diff --git a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt index 53cdba370..d4905cb41 100644 --- a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt @@ -96,6 +96,54 @@ internal class MyAccountAPIMockServer : APIMockServer() { return this } + fun willReturnPasswordEnrollmentChallenge(): MyAccountAPIMockServer { + val json = """ + { + "id": "$PASSWORD_METHOD_ID", + "auth_session": "$SESSION_ID", + "policy": { + "complexity": { + "min_length": 8, + "character_types": ["uppercase", "lowercase", "number", "special"], + "character_type_rule": "three_of_four", + "identical_characters": "block", + "sequential_characters": "block", + "max_length_exceeded": "error" + }, + "profile_data": { + "active": true, + "blocked_fields": ["name", "email"] + }, + "history": { + "active": true, + "size": 5 + }, + "dictionary": { + "active": true, + "default": "en_10k" + } + } + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 202)) + return this + } + + fun willReturnPasswordAuthenticationMethod(): MyAccountAPIMockServer { + val json = """ + { + "id": "$PASSWORD_VERIFIED_METHOD_ID", + "type": "password", + "created_at": "2023-06-15T14:30:25.000Z", + "usage": ["primary"], + "identity_user_id": "user_98765432", + "last_password_reset": "2023-06-15T14:30:25.000Z" + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 201)) + return this + } + fun willReturnErrorForBadRequest(): MyAccountAPIMockServer { val responseBody = """ { @@ -148,5 +196,7 @@ internal class MyAccountAPIMockServer : APIMockServer() { private companion object { private const val SESSION_ID = "SESSION_ID" private const val CHALLENGE = "CHALLENGE" + private const val PASSWORD_METHOD_ID = "password|new" + private const val PASSWORD_VERIFIED_METHOD_ID = "password|pwd_a1b2c3d4e5f6" } } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/util/SSLTestUtils.kt b/auth0/src/test/java/com/auth0/android/util/SSLTestUtils.kt index 542a8e4b6..897d71123 100644 --- a/auth0/src/test/java/com/auth0/android/util/SSLTestUtils.kt +++ b/auth0/src/test/java/com/auth0/android/util/SSLTestUtils.kt @@ -31,15 +31,17 @@ internal object SSLTestUtils { .heldCertificate(localhostCertificate) .build() - testClient = DefaultClient( - defaultHeaders = mapOf(), - readTimeout = 10, - connectTimeout = 10, - enableLogging = false, - gson = GsonProvider.gson, - sslSocketFactory = clientCertificates.sslSocketFactory(), - trustManager = clientCertificates.trustManager - ) + testClient = DefaultClient.Builder() + .connectTimeout(10) + .readTimeout(10) + .defaultHeaders(mapOf()) + .enableLogging(false) + .gson(GsonProvider.gson) + .sslSocketFactory( + clientCertificates.sslSocketFactory(), + clientCertificates.trustManager + ) + .build() } fun createMockWebServer(): MockWebServer { diff --git a/build.gradle b/build.gradle index 316d84146..7f94c35eb 100644 --- a/build.gradle +++ b/build.gradle @@ -1,21 +1,14 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.8.22" + ext.kotlin_version = "2.0.21" repositories { google() mavenCentral() - //noinspection JcenterRepositoryObsolete - jcenter() { - content { - // https://youtrack.jetbrains.com/issue/KT-44730 - includeModule("org.jetbrains.trove4j", "trove4j") - } - } } dependencies { - classpath 'com.android.tools.build:gradle:7.4.0' + classpath 'com.android.tools.build:gradle:8.10.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jacoco:org.jacoco.core:0.8.5" + classpath "org.jacoco:org.jacoco.core:0.8.12" } } @@ -48,14 +41,5 @@ allprojects { repositories { google() mavenCentral() - //noinspection JcenterRepositoryObsolete - jcenter() { - content { - // https://youtrack.jetbrains.com/issue/KT-44730 - includeModule("org.jetbrains.trove4j", "trove4j") - includeModule("com.soywiz.korlibs.korte", "korte-jvm") - includeModule("org.jetbrains.kotlinx", "kotlinx-html-jvm") - } - } } } diff --git a/gradle.properties b/gradle.properties index 0dda7285b..0ef1dd886 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,10 +21,9 @@ POM_DEVELOPER_EMAIL=oss@auth0.com org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -# Adding this here temporarily to fix the build with compileSdKVersion 35. Remove this when migrate to gradle 8 -android.aapt2Version=8.6.1-11315950 \ No newline at end of file +# CI/CD Performance +org.gradle.caching=true +org.gradle.parallel=true \ No newline at end of file diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 5a1e3c327..7d341b02c 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -1,7 +1,7 @@ apply plugin: 'jacoco' jacoco { - toolVersion = "0.8.5" + toolVersion = "0.8.12" } android { @@ -45,8 +45,8 @@ afterEvaluate { executionData.from = "${buildDir}/jacoco/${testTaskName}.exec" reports { - xml.enabled = true - html.enabled = true + xml.required = true + html.required = true } } jacocoTestReportTask.dependsOn reportTask diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2ec77e51a..81aa1c044 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/proguard/proguard-jetpack.pro b/proguard/proguard-jetpack.pro deleted file mode 100644 index 6254a5381..000000000 --- a/proguard/proguard-jetpack.pro +++ /dev/null @@ -1,6 +0,0 @@ -# Jetpack libraries - --if class androidx.credentials.CredentialManager --keep class androidx.credentials.playservices.** { - *; -} \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle index 530d63778..071018d4a 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -4,11 +4,12 @@ plugins { } android { - compileSdkVersion 35 + namespace 'com.auth0.sample' + compileSdk 36 defaultConfig { - minSdkVersion 24 - targetSdkVersion 35 + minSdk 26 + targetSdk 36 versionCode 1 versionName "1.0" @@ -35,11 +36,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } } @@ -52,10 +53,10 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' - implementation "androidx.credentials:credentials-play-services-auth:1.3.0" - implementation "androidx.credentials:credentials:1.3.0" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' implementation 'com.google.code.gson:gson:2.8.9' + implementation "androidx.credentials:credentials-play-services-auth:1.3.0" + implementation "androidx.credentials:credentials:1.3.0" } \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 8f2c85b74..8b0b759e2 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + diff --git a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt index 918de531b..6a5922a54 100644 --- a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt +++ b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt @@ -27,8 +27,6 @@ import com.auth0.android.authentication.storage.LocalAuthenticationOptions import com.auth0.android.authentication.storage.SecureCredentialsManager import com.auth0.android.authentication.storage.SharedPreferencesStorage import com.auth0.android.callback.Callback -import com.auth0.android.management.ManagementException -import com.auth0.android.management.UsersAPIClient import com.auth0.android.provider.WebAuthProvider import com.auth0.android.request.DefaultClient import com.auth0.android.request.PublicKeyCredentials @@ -36,7 +34,6 @@ import com.auth0.android.request.UserData import com.auth0.android.result.Credentials import com.auth0.android.result.PasskeyChallenge import com.auth0.android.result.PasskeyRegistrationChallenge -import com.auth0.android.result.UserProfile import com.auth0.sample.databinding.FragmentDatabaseLoginBinding import com.google.android.material.snackbar.Snackbar import com.google.gson.Gson @@ -50,7 +47,7 @@ import java.util.concurrent.Executors */ class DatabaseLoginFragment : Fragment() { - private val scope = "openid profile email read:current_user update:current_user_metadata" + private val scope = "openid profile email offline_access" private val account: Auth0 by lazy { // -- REPLACE this credentials with your own Auth0 app credentials! @@ -59,7 +56,9 @@ class DatabaseLoginFragment : Fragment() { getString(R.string.com_auth0_domain) ) // Only enable network traffic logging on production environments! - account.networkingClient = DefaultClient(enableLogging = true) + account.networkingClient = DefaultClient.Builder() + .enableLogging(true) + .build() account } @@ -78,8 +77,8 @@ class DatabaseLoginFragment : Fragment() { private val secureCredentialsManager: SecureCredentialsManager by lazy { val storage = SharedPreferencesStorage(requireContext()) val manager = SecureCredentialsManager( + authenticationApiClient, requireContext(), - account, storage, requireActivity(), localAuthenticationOptions @@ -102,7 +101,7 @@ class DatabaseLoginFragment : Fragment() { .setDeviceCredentialFallback(true) .build() - private val callback = object: Callback { + private val callback = object : Callback { override fun onSuccess(result: Credentials) { credentialsManager.saveCredentials(result) Snackbar.make( @@ -185,22 +184,6 @@ class DatabaseLoginFragment : Fragment() { getCredsAsync() } } - binding.btGetProfile.setOnClickListener { - getProfile() - } - binding.btGetProfileAsync.setOnClickListener { - launchAsync { - getProfileAsync() - } - } - binding.btUpdateMeta.setOnClickListener { - updateMeta() - } - binding.btUpdateMetaAsync.setOnClickListener { - launchAsync { - updateMetaAsync() - } - } return binding.root } @@ -344,7 +327,8 @@ class DatabaseLoginFragment : Fragment() { } private fun getCreds() { - credentialsManager.getCredentials(null, + credentialsManager.getCredentials( + null, 300, emptyMap(), emptyMap(), @@ -407,104 +391,6 @@ class DatabaseLoginFragment : Fragment() { } } - private fun getProfile() { - credentialsManager.getCredentials(object : - Callback { - override fun onSuccess(result: Credentials) { - val users = UsersAPIClient(account, result.accessToken) - users.getProfile(result.user.getId()!!) - .start(object : Callback { - override fun onFailure(error: ManagementException) { - Snackbar.make( - requireView(), error.getDescription(), Snackbar.LENGTH_LONG - ).show() - } - - override fun onSuccess(result: UserProfile) { - Snackbar.make( - requireView(), - "Got profile for ${result.name}", - Snackbar.LENGTH_LONG - ).show() - } - }) - } - - override fun onFailure(error: CredentialsManagerException) { - Snackbar.make(requireView(), "${error.message}", Snackbar.LENGTH_LONG).show() - } - }) - } - - private suspend fun getProfileAsync() { - try { - val credentials = credentialsManager.awaitCredentials() - val users = UsersAPIClient(account, credentials.accessToken) - val user = users.getProfile(credentials.user.getId()!!).await() - Snackbar.make( - requireView(), "Got profile for ${user.name}", Snackbar.LENGTH_LONG - ).show() - } catch (error: CredentialsManagerException) { - Snackbar.make(requireView(), "${error.message}", Snackbar.LENGTH_LONG).show() - } catch (error: ManagementException) { - Snackbar.make(requireView(), error.getDescription(), Snackbar.LENGTH_LONG).show() - } - } - - private fun updateMeta() { - val metadata = mapOf( - "random" to (0..100).random(), - ) - - credentialsManager.getCredentials(object : - Callback { - override fun onSuccess(result: Credentials) { - val users = UsersAPIClient(account, result.accessToken) - users.updateMetadata(result.user.getId()!!, metadata) - .start(object : Callback { - override fun onFailure(error: ManagementException) { - Snackbar.make( - requireView(), error.getDescription(), Snackbar.LENGTH_LONG - ).show() - } - - override fun onSuccess(result: UserProfile) { - Snackbar.make( - requireView(), - "Updated metadata for ${result.name} to ${result.getUserMetadata()}", - Snackbar.LENGTH_LONG - ).show() - } - }) - } - - override fun onFailure(error: CredentialsManagerException) { - Snackbar.make(requireView(), "${error.message}", Snackbar.LENGTH_LONG).show() - } - }) - } - - private suspend fun updateMetaAsync() { - val metadata = mapOf( - "random" to (0..100).random(), - ) - - try { - val credentials = credentialsManager.awaitCredentials() - val users = UsersAPIClient(account, credentials.accessToken) - val user = users.updateMetadata(credentials.user.getId()!!, metadata).await() - Snackbar.make( - requireView(), - "Updated metadata for ${user.name} to ${user.getUserMetadata()}", - Snackbar.LENGTH_LONG - ).show() - } catch (error: CredentialsManagerException) { - Snackbar.make(requireView(), "${error.message}", Snackbar.LENGTH_LONG).show() - } catch (error: ManagementException) { - Snackbar.make(requireView(), error.getDescription(), Snackbar.LENGTH_LONG).show() - } - } - private fun launchAsync(runnable: suspend () -> Unit) { //Use a better scope like lifecycleScope or viewModelScope GlobalScope.launch(Dispatchers.Main) { @@ -527,7 +413,8 @@ class DatabaseLoginFragment : Fragment() { ) var response: CreatePublicKeyCredentialResponse? - credentialManager.createCredentialAsync(requireContext(), + credentialManager.createCredentialAsync( + requireContext(), request, CancellationSignal(), Executors.newSingleThreadExecutor(), @@ -594,7 +481,8 @@ class DatabaseLoginFragment : Fragment() { listOf(request) ) - credentialManager.getCredentialAsync(requireContext(), + credentialManager.getCredentialAsync( + requireContext(), getCredRequest, CancellationSignal(), Executors.newSingleThreadExecutor(), diff --git a/sample/src/main/res/layout/fragment_database_login.xml b/sample/src/main/res/layout/fragment_database_login.xml index 5d3731b25..4712c6775 100644 --- a/sample/src/main/res/layout/fragment_database_login.xml +++ b/sample/src/main/res/layout/fragment_database_login.xml @@ -146,17 +146,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/btWebLogoutAsync" /> - -