From 806cdad3886c920a5491177a305db9acd5c6ac9a Mon Sep 17 00:00:00 2001 From: atakavci <58048133+atakavci@users.noreply.github.com> Date: Fri, 20 Dec 2024 09:29:33 +0300 Subject: [PATCH] Token based authentication extension libraries (#1) * draft core and entraid * - deploy parent pom - core snapshot * entra id integration and snapshot workflows * install local maven * fix testcontext * set azure params * build into local dependency * release workflow * remove jedis build from enraid_snapshot * - add ManagedentityInfo - add ServicePrincipalInfo - unwrap ExecutionException - auth with managedId s - remove EntraIDTokenAuthConfig * - remove doctest - add unit and integration tests - add executor to shutdown in TokenManager (review from Ivo) * - experimental release with branch - remove snapshot * - fix failed release * - support full customization of different MSAL application types and advanced configurations with EntraIDTokenAuthConfigBuilder - add more unit tests * - fix missing assignment * - cleanup - fix cert issue - drop jedis integration tests (move to jedis) - add unit tests - change textcontext to load demand * - release drafter - make config builders generic - force refresh with managedidentity - skipcache with confidentialclientapp - add builder cloners * - change exception propogation/handling - fix units tests - set DEFAULT_EXPIRATION_REFRESH_RATIO in entraid 0.75 * - add getuser to Token interface - set user in JWToken * remove all jedis config and dependency * review from @tishun - licesing statement - checkout action version - drop useless file * review from @tishun - attemp to increase readibility and establish a clear seperation of responsibilties via breaking tokenmanager into multiple classes and interfaces. - added some comments to explain the logic --- .github/ISSUE_TEMPLATE | 32 + .github/dependabot.yml | 7 + .github/spellcheck-settings.yml | 28 + .github/wordlist.txt | 306 +++++++++ .github/workflows/codeql-analysis.yml | 63 ++ .github/workflows/core_integration.yml | 2 +- .github/workflows/core_snapshot.yml | 89 +-- .github/workflows/entraid_integration.yml | 133 ++-- .github/workflows/entraid_snapshot.yml | 19 +- .github/workflows/spellcheck.yml | 14 + .github/workflows/stale-issues.yml | 25 + .github/workflows/version-and-release.yml | 56 ++ LICENSE | 21 + core/pom.xml | 274 ++++++++ .../authentication/core/AuthXException.java | 18 + .../authentication/core/Dispatcher.java | 60 ++ .../authentication/core/IdentityProvider.java | 12 + .../core/IdentityProviderConfig.java | 12 + .../authentication/core/RenewalScheduler.java | 66 ++ .../authentication/core/RenewalTask.java | 27 + .../clients/authentication/core/Request.java | 15 + .../authentication/core/SimpleToken.java | 63 ++ .../clients/authentication/core/Token.java | 25 + .../authentication/core/TokenAuthConfig.java | 86 +++ .../authentication/core/TokenListener.java | 14 + .../authentication/core/TokenManager.java | 163 +++++ .../core/TokenManagerConfig.java | 78 +++ .../core/TokenRequestException.java | 31 + .../CoreAuthenticationUnitTests.java | 265 ++++++++ entraid/pom.xml | 282 ++++++++ .../entraid/EntraIDIdentityProvider.java | 129 ++++ .../EntraIDIdentityProviderConfig.java | 39 ++ .../EntraIDTokenAuthConfigBuilder.java | 174 +++++ .../authentication/entraid/JWToken.java | 87 +++ .../entraid/ManagedIdentityInfo.java | 56 ++ .../entraid/RedisEntraIDException.java | 20 + .../entraid/ServicePrincipalInfo.java | 64 ++ .../EntraIDIntegrationTests.java | 42 ++ .../authentication/EntraIDUnitTests.java | 623 ++++++++++++++++++ .../clients/authentication/TestContext.java | 128 ++++ hbase-formatter.xml | 291 ++++++++ pom.xml | 21 + 42 files changed, 3839 insertions(+), 121 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE create mode 100644 .github/dependabot.yml create mode 100644 .github/spellcheck-settings.yml create mode 100644 .github/wordlist.txt create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/spellcheck.yml create mode 100644 .github/workflows/stale-issues.yml create mode 100644 LICENSE create mode 100644 core/pom.xml create mode 100644 core/src/main/java/redis/clients/authentication/core/AuthXException.java create mode 100644 core/src/main/java/redis/clients/authentication/core/Dispatcher.java create mode 100644 core/src/main/java/redis/clients/authentication/core/IdentityProvider.java create mode 100644 core/src/main/java/redis/clients/authentication/core/IdentityProviderConfig.java create mode 100644 core/src/main/java/redis/clients/authentication/core/RenewalScheduler.java create mode 100644 core/src/main/java/redis/clients/authentication/core/RenewalTask.java create mode 100644 core/src/main/java/redis/clients/authentication/core/Request.java create mode 100644 core/src/main/java/redis/clients/authentication/core/SimpleToken.java create mode 100644 core/src/main/java/redis/clients/authentication/core/Token.java create mode 100644 core/src/main/java/redis/clients/authentication/core/TokenAuthConfig.java create mode 100644 core/src/main/java/redis/clients/authentication/core/TokenListener.java create mode 100644 core/src/main/java/redis/clients/authentication/core/TokenManager.java create mode 100644 core/src/main/java/redis/clients/authentication/core/TokenManagerConfig.java create mode 100644 core/src/main/java/redis/clients/authentication/core/TokenRequestException.java create mode 100644 core/src/test/java/redis/clients/authentication/CoreAuthenticationUnitTests.java create mode 100644 entraid/pom.xml create mode 100644 entraid/src/main/java/redis/clients/authentication/entraid/EntraIDIdentityProvider.java create mode 100644 entraid/src/main/java/redis/clients/authentication/entraid/EntraIDIdentityProviderConfig.java create mode 100644 entraid/src/main/java/redis/clients/authentication/entraid/EntraIDTokenAuthConfigBuilder.java create mode 100644 entraid/src/main/java/redis/clients/authentication/entraid/JWToken.java create mode 100644 entraid/src/main/java/redis/clients/authentication/entraid/ManagedIdentityInfo.java create mode 100644 entraid/src/main/java/redis/clients/authentication/entraid/RedisEntraIDException.java create mode 100644 entraid/src/main/java/redis/clients/authentication/entraid/ServicePrincipalInfo.java create mode 100644 entraid/src/test/java/redis/clients/authentication/EntraIDIntegrationTests.java create mode 100644 entraid/src/test/java/redis/clients/authentication/EntraIDUnitTests.java create mode 100644 entraid/src/test/java/redis/clients/authentication/TestContext.java create mode 100644 hbase-formatter.xml create mode 100644 pom.xml diff --git a/.github/ISSUE_TEMPLATE b/.github/ISSUE_TEMPLATE new file mode 100644 index 0000000..43fccfe --- /dev/null +++ b/.github/ISSUE_TEMPLATE @@ -0,0 +1,32 @@ + + +### Expected behavior + +Write here what you're expecting ... + +### Actual behavior + +Write here what happens instead ... + +### Steps to reproduce: + +Please create a reproducible case of your problem. Make sure +that case repeats consistently and it's not random +1. +2. +3. + +### Redis / EntraID Configuration + +#### ClientLibrary and version: + +#### Redis version: + +#### Java version: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..069e8c5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 + +updates: + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/spellcheck-settings.yml b/.github/spellcheck-settings.yml new file mode 100644 index 0000000..07b400f --- /dev/null +++ b/.github/spellcheck-settings.yml @@ -0,0 +1,28 @@ +matrix: +- name: Markdown + expect_match: false + apsell: + lang: en + d: en_US + ignore-case: true + dictionary: + wordlists: + - .github/wordlist.txt + output: wordlist.dic + pipeline: + - pyspelling.filters.markdown: + markdown_extensions: + - markdown.extensions.extra: + - pyspelling.filters.html: + comments: false + attributes: + - alt + ignores: + - ':matches(code, pre)' + - code + - pre + - blockquote + - img + sources: + - '*.md' + - 'docs/**' diff --git a/.github/wordlist.txt b/.github/wordlist.txt new file mode 100644 index 0000000..32a6f7f --- /dev/null +++ b/.github/wordlist.txt @@ -0,0 +1,306 @@ +!!!Spelling check failed!!! +APM +ARGV +BFCommands +BitOP +BitPosParams +BuilderFactory +CFCommands +CMSCommands +CallNotPermittedException +CircuitBreaker +ClientKillParams +ClusterNode +ClusterNodes +ClusterPipeline +ClusterPubSub +ConnectionPool +CoreCommands +EVAL +EVALSHA +Failback +Failover +FTCreateParams +FTSearchParams +GSON +GenericObjectPool +GenericObjectPoolConfig +GeoAddParams +GeoRadiusParam +GeoRadiusStoreParam +GeoUnit +GraphCommands +Grokzen's +HostAndPort +HostnameVerifier +INCR +IOError +Instrumentations +JDK +JSONArray +JSONCommands +Jaeger +Javadocs +ListPosition +Ludovico +Magnocavallo +McCurdy +NOSCRIPT +NUMPAT +NUMPT +NUMSUB +OSS +OpenCensus +OpenTelemetry +OpenTracing +Otel +POJO +POJOs +PubSub +Queable +READONLY +RediSearch +RediSearchCommands +RedisBloom +RedisCluster +RedisClusterCommands +RedisClusterException +RedisClusters +RedisGraph +RedisInstrumentor +RedisJSON +RedisTimeSeries +SHA +SSLParameters +SSLSocketFactory +SearchCommands +SentinelCommands +SentinelConnectionPool +ShardInfo +Sharded +Solovyov +SortingParams +SpanKind +Specfiying +StatusCode +StreamEntryID +TCP +TOPKCommands +Throwable +TimeSeriesCommands +URI +UnblockType +Uptrace +ValueError +WATCHed +WatchError +XTrimParams +ZAddParams +ZParams +aclDelUser +api +approximateLength +arg +args +async +asyncio +autoclass +automodule +backoff +bdb +behaviour +bitcount +bitop +bitpos +bool +boolean +booleans +bysource +charset +clientId +clientKill +clientUnblock +clusterCountKeysInSlot +clusterKeySlot +configs +consumerName +consumername +cumbersome +dbIndex +dbSize +decr +decrBy +del +destKey +dev +dstKey +dstkey +eg +exc +expireAt +failback +failover +faoliver +firstName +firsttimersonly +fo +genindex +geoadd +georadiusByMemberStore +georadiusStore +getbit +gmail +groupname +hdel +hexists +hincrBy +hincrByFloat +hiredis +hlen +hset +hsetnx +hstrlen +http +idx +iff +incr +incrBy +incrByFloat +ini +json +keyslot +keyspace +keysvalues +kwarg +lastName +lastsave +linsert +linters +llen +localhost +lpush +lpushx +lrem +lua +makeapullrequest +maxLen +maxdepth +maya +memberCoordinateMap +mget +microservice +microservices +millisecondsTimestamp +mset +msetnx +multikey +mykey +newkey +nonatomic +observability +oldkey +opentelemetry +oss +param +params +performant +pexpire +pexpireAt +pfadd +pfcount +pmessage +png +pre +psubscribe +pttl +pubsub +punsubscribe +py +pypi +quickstart +readonly +readwrite +redis +redismodules +reimplemented +reinitialization +renamenx +replicaof +repo +rpush +rpushx +runtime +sadd +scard +scoreMembers +sdiffstore +sedrik +setbit +setnx +setrange +sinterstore +sismember +slowlogLen +smove +sortingParameters +srcKey +srcKeys +srckey +ssl +storeParam +str +strlen +stunnel +subcommands +sunionstore +thevalueofmykey +timeseries +toctree +topk +tox +triaging +ttl +txt +un +unblockType +unicode +unixTime +unlink +untyped +url +virtualenv +waitReplicas +whenver +www +xack +xdel +xgroupDelConsumer +xgroupDestroy +xlen +xtrim +zadd +zcard +zcount +zdiffStore +zincrby +zinterstore +zlexcount +zpopmax +zpopmin +zrandmember +zrandmemberWithScores +zrange +zrangeByLex +zrangeByScore +zrangeByScoreWithScores +zrangeWithScores +zrem +zremrangeByLex +zremrangeByRank +zremrangeByScore +zrevrange +zrevrangeByLex +zrevrangeByScore +zrevrangeByScoreWithScores +zrevrangeWithScores +zunionstore diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..d395145 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,63 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '31 4 * * 4' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: java + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/core_integration.yml b/.github/workflows/core_integration.yml index ca52e91..603abc1 100644 --- a/.github/workflows/core_integration.yml +++ b/.github/workflows/core_integration.yml @@ -40,7 +40,7 @@ jobs: path: | ~/.m2/repository /var/cache/apt - key: entraid-${{hashFiles('**/pom.xml')}} + key: core-${{hashFiles('**/pom.xml')}} - name: Maven offline run: | mvn -q dependency:go-offline diff --git a/.github/workflows/core_snapshot.yml b/.github/workflows/core_snapshot.yml index ca7ad4f..fa44edd 100644 --- a/.github/workflows/core_snapshot.yml +++ b/.github/workflows/core_snapshot.yml @@ -1,46 +1,47 @@ --- -name: Publish Snapshot-Core - -on: - push: - branches: - - main - - '[0-9].x' - workflow_dispatch: - -jobs: - - snapshot: - name: Deploy Snapshot-Core - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./core - steps: - - uses: actions/checkout@v2 - - name: Set up publishing to maven central - uses: actions/setup-java@v2 - with: - java-version: '8' - distribution: 'temurin' - server-id: ossrh - server-username: MAVEN_USERNAME - server-password: MAVEN_PASSWORD - - name: Cache dependencies - uses: actions/cache@v2 - with: - path: | - ~/.m2/repository - /var/cache/apt - key: core-${{hashFiles('**/pom.xml')}} - - name: mvn offline - run: | - mvn -q dependency:go-offline - - name: deploy - run: | - mvn --no-transfer-progress \ - -DskipTests deploy - env: - MAVEN_USERNAME: ${{secrets.OSSH_USERNAME}} - MAVEN_PASSWORD: ${{secrets.OSSH_TOKEN}} + name: Publish Snapshot-Core + + on: + push: + branches: + - main + - '[0-9].x' + workflow_dispatch: + + jobs: + + snapshot: + name: Deploy Snapshot-Core + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./core + steps: + - uses: actions/checkout@v2 + - name: Set up publishing to maven central + uses: actions/setup-java@v2 + with: + java-version: '8' + distribution: 'temurin' + server-id: ossrh + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: | + ~/.m2/repository + /var/cache/apt + key: core-${{hashFiles('**/pom.xml')}} + - name: mvn offline + run: | + mvn -q dependency:go-offline + - name: deploy + run: | + mvn --no-transfer-progress \ + -DskipTests deploy + env: + MAVEN_USERNAME: ${{secrets.OSSH_USERNAME}} + MAVEN_PASSWORD: ${{secrets.OSSH_TOKEN}} + \ No newline at end of file diff --git a/.github/workflows/entraid_integration.yml b/.github/workflows/entraid_integration.yml index ace1543..7e00b6c 100644 --- a/.github/workflows/entraid_integration.yml +++ b/.github/workflows/entraid_integration.yml @@ -1,69 +1,66 @@ ---- +name: Integration-EntraID - name: Integration-EntraID - - on: - push: - paths-ignore: - - 'docs/**' - - '**/*.md' - - '**/*.rst' - branches: - - main - - '[0-9].*' - pull_request: - branches: - - main - - '[0-9].*' - schedule: - - cron: '0 1 * * *' # nightly build - workflow_dispatch: - - jobs: - - build: - name: Build and Test EntraID - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./entraid - steps: - - uses: actions/checkout@v2 - - name: Checkout Jedis repository (tba_draft branch) - uses: actions/checkout@v2 - with: - repository: atakavci/jedis # Replace with the actual jedis repository URL - ref: ali/authx2 - path: jedis # Check out into a subdirectory named `jedis` so it's isolated - - - name: Set up publishing to maven central - uses: actions/setup-java@v2 - with: - java-version: '8' - distribution: 'temurin' - - name: Cache dependencies - uses: actions/cache@v2 - with: - path: | - ~/.m2/repository - /var/cache/apt - key: entraid-${{hashFiles('**/pom.xml')}} - - name: Maven offline - run: | - mvn -q dependency:go-offline - - name: Build and install Core into local repo - run: | - mvn clean install -DskipTests # Skip tests for faster builds, but you can remove the flag if needed - working-directory: ./core - - name: Build and install Jedis supports TBA into local repo - run: | - cd jedis - mvn clean install -DskipTests # Skip tests for faster builds, but you can remove the flag if needed - - name: Build docs - run: | - mvn javadoc:jar - - name: Build with Maven - run: mvn compile - - name: Test with Maven - run: mvn test - \ No newline at end of file +on: + push: + paths-ignore: + - 'docs/**' + - '**/*.md' + - '**/*.rst' + branches: + - main + - '[0-9].*' + pull_request: + branches: + - main + - '[0-9].*' + schedule: + - cron: '0 1 * * *' # nightly build + workflow_dispatch: + +jobs: + + build: + name: Build and Test EntraID + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./entraid + steps: + - uses: actions/checkout@v2 + + - name: Set up publishing to maven central + uses: actions/setup-java@v2 + with: + java-version: '8' + distribution: 'temurin' + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: | + ~/.m2/repository + /var/cache/apt + key: entraid-${{hashFiles('**/pom.xml')}} + + - name: Maven offline-core + run: | + mvn -q dependency:go-offline + working-directory: ./core + - name: Build and install Core into local repo + run: | + mvn clean install -DskipTests # Skip tests for faster builds, but you can remove the flag if needed + working-directory: ./core + + - name: Build docs + run: | + mvn javadoc:jar + - name: Build with Maven + run: mvn compile + - name: Test with Maven + run: mvn test + env: + AZURE_CLIENT_ID: ${{secrets.AZURE_CLIENT_ID}} + AZURE_AUTHORITY: ${{secrets.AZURE_AUTHORITY}} + AZURE_CLIENT_SECRET: ${{secrets.AZURE_CLIENT_SECRET}} + AZURE_CERT: ${{secrets.AZURE_CERT}} + AZURE_PRIVATE_KEY: ${{secrets.AZURE_PRIVATE_KEY}} + AZURE_REDIS_SCOPES: ${{secrets.AZURE_REDIS_SCOPES}} diff --git a/.github/workflows/entraid_snapshot.yml b/.github/workflows/entraid_snapshot.yml index 81c19e2..aec0f3a 100644 --- a/.github/workflows/entraid_snapshot.yml +++ b/.github/workflows/entraid_snapshot.yml @@ -19,12 +19,6 @@ working-directory: ./entraid steps: - uses: actions/checkout@v2 - - name: Checkout Jedis repository (tba_draft branch) - uses: actions/checkout@v2 - with: - repository: atakavci/jedis # Replace with the actual jedis repository URL - ref: ali/authx2 - path: jedis # Check out into a subdirectory named `jedis` so it's isolated - name: Set up publishing to maven central uses: actions/setup-java@v2 @@ -41,14 +35,23 @@ ~/.m2/repository /var/cache/apt key: entraid-${{hashFiles('**/pom.xml')}} + + - name: Maven offline-core + run: | + mvn -q dependency:go-offline + working-directory: ./core + - name: Build and install Core into local repo + run: | + mvn clean install -DskipTests # Skip tests for faster builds, but you can remove the flag if needed + working-directory: ./core + - name: Maven offline run: | mvn -q dependency:go-offline - name: deploy run: | mvn --no-transfer-progress \ - -DskipTests deploy + -DskipTests -Dmaven.test.skip=true deploy env: MAVEN_USERNAME: ${{secrets.OSSH_USERNAME}} MAVEN_PASSWORD: ${{secrets.OSSH_TOKEN}} - \ No newline at end of file diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml new file mode 100644 index 0000000..4588835 --- /dev/null +++ b/.github/workflows/spellcheck.yml @@ -0,0 +1,14 @@ +name: Spellcheck +on: + pull_request: +jobs: + check-spelling: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Check Spelling + uses: rojopolis/spellcheck-github-actions@0.33.1 + with: + config_path: .github/spellcheck-settings.yml + task_name: Markdown diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 0000000..d511f4a --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,25 @@ +name: Close stale issues +on: + schedule: + - cron: "0 0 * * *" + +permissions: {} +jobs: + stale: + permissions: + issues: write # to close stale issues (actions/stale) + pull-requests: write # to close stale PRs (actions/stale) + + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue is marked stale. It will be closed in 30 days if it is not updated.' + stale-pr-message: 'This pull request is marked stale. It will be closed in 30 days if it is not updated.' + days-before-stale: 365 + days-before-close: 30 + stale-issue-label: "stale" + stale-pr-label: "stale" + operations-per-run: 10 + remove-stale-when-updated: false diff --git a/.github/workflows/version-and-release.yml b/.github/workflows/version-and-release.yml index 09623d6..202fe8a 100644 --- a/.github/workflows/version-and-release.yml +++ b/.github/workflows/version-and-release.yml @@ -11,3 +11,59 @@ jobs: steps: - uses: actions/checkout@v2 + + - name: get version from tag + id: get_version + run: | + realversion="${GITHUB_REF/refs\/tags\//}" + realversion="${realversion//v/}" + echo "VERSION=$realversion" >> $GITHUB_OUTPUT + + - name: Set up publishing to maven central + uses: actions/setup-java@v2 + with: + java-version: '8' + distribution: 'temurin' + server-id: ossrh + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + + - name: Install gpg key + run: | + cat <(echo -e "${{ secrets.OSSH_GPG_SECRET_KEY }}") | gpg --batch --import + gpg --list-secret-keys --keyid-format LONG + + - name: mvn versions - Core + run: mvn versions:set -DnewVersion=${{ steps.get_version.outputs.VERSION }} + working-directory: ./core + + - name: Publish - Core + run: | + mvn --no-transfer-progress \ + --batch-mode \ + -Dgpg.passphrase='${{ secrets.OSSH_GPG_SECRET_KEY_PASSWORD }}' \ + -DskipTests deploy -P release + env: + MAVEN_USERNAME: ${{secrets.OSSH_USERNAME}} + MAVEN_PASSWORD: ${{secrets.OSSH_TOKEN}} + working-directory: ./core + continue-on-error: true # This step will not stop the job even if it fails + + - name: mvn versions - EntraID + run: mvn versions:set -DnewVersion=${{ steps.get_version.outputs.VERSION }} + working-directory: ./entraid + + - name: set release versions + run: mvn versions:use-releases -DallowSnapshots=false -DgenerateBackupPoms=false + working-directory: ./entraid + + - name: Publish - EntraID + run: | + mvn --no-transfer-progress \ + --batch-mode \ + -Dgpg.passphrase='${{ secrets.OSSH_GPG_SECRET_KEY_PASSWORD }}' \ + -DskipTests -Dmaven.test.skip=true deploy -P release + env: + MAVEN_USERNAME: ${{secrets.OSSH_USERNAME}} + MAVEN_PASSWORD: ${{secrets.OSSH_TOKEN}} + working-directory: ./entraid diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..15c4dd5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2023, Redis, inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..843b73d --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,274 @@ + + + + org.sonatype.oss + oss-parent + 7 + + + 4.0.0 + jar + redis.clients.authentication + redis-authx-core + 0.1.0-SNAPSHOT + Redis AuthX Core is the core lib of an extension for Redis Java Clients to support token-based authentication. + https://github.com/redis/redis-authx-core + + + + Redis Authx Mailing List + redis_authx@googlegroups.com + + https://groups.google.com/group/redis_authx + + + + + + + MIT + https://github.com/redis/redis-authx-core/blob/master/LICENSE + repo + + + + + github + https://github.com/redis/redis-authx-core/issues + + + + scm:git:git@github.com:redis/redis-authx-core.git + scm:git:git@github.com:redis/redis-authx-core.git + scm:git:git@github.com:redis/redis-authx-core.git + redis-authx-core-0.1.0 + + + + github + redis.clients.authentication.core + 1.7.36 + 1.7.1 + 2.18.0 + 3.5.1 + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + junit + junit + 4.13.2 + test + + + org.mockito + mockito-inline + 4.11.0 + test + + + org.hamcrest + hamcrest + 3.0 + test + + + org.awaitility + awaitility + 4.2.2 + test + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + + src/main/resources + true + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + report + test + + report + + + + + + maven-compiler-plugin + 3.13.0 + + 1.8 + 1.8 + + + + maven-surefire-plugin + ${maven.surefire.version} + + + ${redis-hosts} + + + **/examples/*Example.java + + + + + + maven-source-plugin + 3.3.1 + + true + + + + attach-sources + + jar + + + + + + maven-javadoc-plugin + 3.10.1 + + 8 + false + + + + + + attach-javadoc + + jar + + + + + + maven-release-plugin + 3.1.1 + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.7.0 + true + + ossrh + https://oss.sonatype.org/ + true + + + + com.googlecode.maven-java-formatter-plugin + maven-java-formatter-plugin + 0.4 + + ${project.basedir}/hbase-formatter.xml + + + + maven-jar-plugin + 3.4.2 + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + ${core.module.name} + + + + + + org.apache.felix + maven-bundle-plugin + 5.1.9 + + + bundle-manifest + process-classes + + manifest + + + + + + + + + release + + + + + maven-gpg-plugin + 3.2.7 + + + --pinentry-mode + loopback + + + + + sign-artifacts + verify + + sign + + + + + + + + + doctests + + + + maven-surefire-plugin + ${maven.surefire.version} + + **/examples/*Example.java + + + + + + + diff --git a/core/src/main/java/redis/clients/authentication/core/AuthXException.java b/core/src/main/java/redis/clients/authentication/core/AuthXException.java new file mode 100644 index 0000000..dc0981f --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/AuthXException.java @@ -0,0 +1,18 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +public class AuthXException extends RuntimeException { + + public AuthXException(String message) { + super(message); + } + + public AuthXException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/redis/clients/authentication/core/Dispatcher.java b/core/src/main/java/redis/clients/authentication/core/Dispatcher.java new file mode 100644 index 0000000..58f6de4 --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/Dispatcher.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors All rights reserved. Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Dispatches requests to the identity provider asynchronously with a timeout for the request execution. + */ +class Dispatcher { + private ExecutorService executor = Executors.newFixedThreadPool(2); + private Exception error; + private long tokenRequestExecTimeoutInMs; + private IdentityProvider identityProvider; + private Logger logger = LoggerFactory.getLogger(getClass()); + + public Dispatcher(IdentityProvider provider, long tokenRequestExecTimeoutInMs) { + this.tokenRequestExecTimeoutInMs = tokenRequestExecTimeoutInMs; + this.identityProvider = provider; + } + + /** + * Dispatches a request to the identity provider asynchronously + * with a timeout for the request execution and returns the request object + * @return + */ + public Request requestTokenAsync() { + Future request = executor.submit(() -> requestToken()); + return () -> request.get(tokenRequestExecTimeoutInMs, TimeUnit.MILLISECONDS); + } + + public Exception getError() { + return error; + } + + public void stop() { + executor.shutdown(); + } + + /** + * Makes the actual request to the identity provider + * @return + */ + private Token requestToken() { + error = null; + try { + return identityProvider.requestToken(); + } catch (Exception e) { + error = e; + logger.error("Request to identity provider failed with message: " + e.getMessage(), e); + throw e; + } + } +} diff --git a/core/src/main/java/redis/clients/authentication/core/IdentityProvider.java b/core/src/main/java/redis/clients/authentication/core/IdentityProvider.java new file mode 100644 index 0000000..be9717d --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/IdentityProvider.java @@ -0,0 +1,12 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +public interface IdentityProvider { + + Token requestToken(); +} \ No newline at end of file diff --git a/core/src/main/java/redis/clients/authentication/core/IdentityProviderConfig.java b/core/src/main/java/redis/clients/authentication/core/IdentityProviderConfig.java new file mode 100644 index 0000000..c155af1 --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/IdentityProviderConfig.java @@ -0,0 +1,12 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +public interface IdentityProviderConfig { + + public IdentityProvider getProvider(); +} diff --git a/core/src/main/java/redis/clients/authentication/core/RenewalScheduler.java b/core/src/main/java/redis/clients/authentication/core/RenewalScheduler.java new file mode 100644 index 0000000..97d70d3 --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/RenewalScheduler.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors All rights reserved. Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * Schedules a task for token renewal. + */ +class RenewalScheduler { + private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private RenewalTask lastTask; + private Supplier renewToken; + private boolean stopped = false; + + public RenewalScheduler(Supplier renewToken) { + this.renewToken = renewToken; + } + + /** + * Schedules a task to renew the token with a given delay + * Wraps the supplier function into RenewalTask + * @param delay + * @return + */ + public RenewalTask scheduleNext(long delay) { + // Schedule the task to run after the given delay + lastTask = new RenewalTask( + scheduler.schedule(() -> renewToken.get(), delay, TimeUnit.MILLISECONDS)); + return lastTask; + } + + /** + * Returns the last task that was scheduled + * @return + */ + public RenewalTask getLastTask() { + return lastTask; + } + + /** + * Waits for given task to complete + * If there is an execution error in the task, it throws the same exception + * It keeps following if there are consecutive tasks until a non-null result is returned or an exception occurs + * This makes the caller thread to wait until a first token is received with or after the pendingTask + * @param pendingTask + * @throws InterruptedException + * @throws ExecutionException + */ + public void waitFor(RenewalTask pendingTask) throws InterruptedException, ExecutionException { + while (!stopped && pendingTask.waitForResultOrError() == null) { + pendingTask = getLastTask(); + } + } + + public void stop() { + stopped = true; + lastTask.cancel(); + scheduler.shutdown(); + } +} diff --git a/core/src/main/java/redis/clients/authentication/core/RenewalTask.java b/core/src/main/java/redis/clients/authentication/core/RenewalTask.java new file mode 100644 index 0000000..4b6bf98 --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/RenewalTask.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledFuture; + +class RenewalTask { + + private ScheduledFuture future; + + public RenewalTask(ScheduledFuture future) { + this.future = future; + } + + public Token waitForResultOrError() throws InterruptedException, ExecutionException { + return future.get(); + } + + public void cancel() { + future.cancel(true); + } +} diff --git a/core/src/main/java/redis/clients/authentication/core/Request.java b/core/src/main/java/redis/clients/authentication/core/Request.java new file mode 100644 index 0000000..adebc44 --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/Request.java @@ -0,0 +1,15 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +interface Request { + + public Token getResult() throws InterruptedException, ExecutionException, TimeoutException; +} diff --git a/core/src/main/java/redis/clients/authentication/core/SimpleToken.java b/core/src/main/java/redis/clients/authentication/core/SimpleToken.java new file mode 100644 index 0000000..e9ae58a --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/SimpleToken.java @@ -0,0 +1,63 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +import java.util.Map; + +public class SimpleToken implements Token { + + private String user; + private String value; + private long expiresAt; + private long receivedAt; + private Map claims; + + public SimpleToken(String user, String value, long expiresAt, long receivedAt, + Map claims) { + this.user = user; + this.value = value; + this.expiresAt = expiresAt; + this.receivedAt = receivedAt; + this.claims = claims; + } + + @Override + public String getUser() { + return user; + } + + @Override + public String getValue() { + return value; + } + + @Override + public long getExpiresAt() { + return expiresAt; + } + + @Override + public long getReceivedAt() { + return receivedAt; + } + + @Override + public T tryGet(String key, Class clazz) { + return (T) claims.get(key); + } + + @Override + public boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + + @Override + public long ttl() { + return expiresAt - System.currentTimeMillis(); + } + +} \ No newline at end of file diff --git a/core/src/main/java/redis/clients/authentication/core/Token.java b/core/src/main/java/redis/clients/authentication/core/Token.java new file mode 100644 index 0000000..fb6142f --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/Token.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +public interface Token { + + public String getUser(); + + public String getValue(); + + public long getExpiresAt(); + + public long getReceivedAt(); + + public boolean isExpired(); + + public long ttl(); + + public T tryGet(String key, Class clazz); + +} \ No newline at end of file diff --git a/core/src/main/java/redis/clients/authentication/core/TokenAuthConfig.java b/core/src/main/java/redis/clients/authentication/core/TokenAuthConfig.java new file mode 100644 index 0000000..9fed55c --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/TokenAuthConfig.java @@ -0,0 +1,86 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +public class TokenAuthConfig { + + private TokenManagerConfig tokenManagerConfig; + private IdentityProviderConfig identityProviderConfig; + + public TokenAuthConfig(TokenManagerConfig tokenManagerConfig, + IdentityProviderConfig identityProviderConfig) { + this.tokenManagerConfig = tokenManagerConfig; + this.identityProviderConfig = identityProviderConfig; + } + + public TokenManagerConfig getTokenManagerConfig() { + return tokenManagerConfig; + } + + public IdentityProviderConfig getIdentityProviderConfig() { + return identityProviderConfig; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder> { + private IdentityProviderConfig identityProviderConfig; + private int lowerRefreshBoundMillis; + private float expirationRefreshRatio; + private int tokenRequestExecTimeoutInMs; + private int maxAttemptsToRetry; + private int delayInMsToRetry; + + public T expirationRefreshRatio(float expirationRefreshRatio) { + this.expirationRefreshRatio = expirationRefreshRatio; + return (T) this; + } + + public T lowerRefreshBoundMillis(int lowerRefreshBoundMillis) { + this.lowerRefreshBoundMillis = lowerRefreshBoundMillis; + return (T) this; + } + + public T tokenRequestExecTimeoutInMs(int tokenRequestExecTimeoutInMs) { + this.tokenRequestExecTimeoutInMs = tokenRequestExecTimeoutInMs; + return (T) this; + } + + public T maxAttemptsToRetry(int maxAttemptsToRetry) { + this.maxAttemptsToRetry = maxAttemptsToRetry; + return (T) this; + } + + public T delayInMsToRetry(int delayInMsToRetry) { + this.delayInMsToRetry = delayInMsToRetry; + return (T) this; + } + + public T identityProviderConfig(IdentityProviderConfig identityProviderConfig) { + this.identityProviderConfig = identityProviderConfig; + return (T) this; + } + + public TokenAuthConfig build() { + return new TokenAuthConfig(new TokenManagerConfig(expirationRefreshRatio, + lowerRefreshBoundMillis, tokenRequestExecTimeoutInMs, + new TokenManagerConfig.RetryPolicy(maxAttemptsToRetry, delayInMsToRetry)), + identityProviderConfig); + } + + public static Builder from(Builder sample) { + return new Builder().expirationRefreshRatio(sample.expirationRefreshRatio) + .lowerRefreshBoundMillis(sample.lowerRefreshBoundMillis) + .tokenRequestExecTimeoutInMs(sample.tokenRequestExecTimeoutInMs) + .maxAttemptsToRetry(sample.maxAttemptsToRetry) + .delayInMsToRetry(sample.delayInMsToRetry) + .identityProviderConfig(sample.identityProviderConfig); + } + } +} diff --git a/core/src/main/java/redis/clients/authentication/core/TokenListener.java b/core/src/main/java/redis/clients/authentication/core/TokenListener.java new file mode 100644 index 0000000..9ea8c75 --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/TokenListener.java @@ -0,0 +1,14 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +public interface TokenListener { + + void onTokenRenewed(Token newToken); + + void onError(Exception reason); +} diff --git a/core/src/main/java/redis/clients/authentication/core/TokenManager.java b/core/src/main/java/redis/clients/authentication/core/TokenManager.java new file mode 100644 index 0000000..30bc1b4 --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/TokenManager.java @@ -0,0 +1,163 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors All rights reserved. Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public class TokenManager { + + private TokenManagerConfig tokenManagerConfig; + private TokenListener listener; + private boolean stopped = false; + private AtomicInteger numberOfRetries = new AtomicInteger(0); + private Token currentToken = null; + private AtomicBoolean started = new AtomicBoolean(false); + private Dispatcher dispatcher; + private RenewalScheduler renewalScheduler; + private int retryDelay; + private int maxRetries; + + public TokenManager(IdentityProvider identityProvider, TokenManagerConfig tokenManagerConfig) { + this.tokenManagerConfig = tokenManagerConfig; + maxRetries = tokenManagerConfig.getRetryPolicy().getMaxAttempts(); + retryDelay = tokenManagerConfig.getRetryPolicy().getdelayInMs(); + renewalScheduler = new RenewalScheduler(this::renewToken); + dispatcher = new Dispatcher(identityProvider, + tokenManagerConfig.getTokenRequestExecTimeoutInMs()); + } + + /** + * Starts the token manager with given listener, blocks if blockForInitialToken is true + * @param listener + * @param blockForInitialToken + */ + public void start(TokenListener listener, boolean blockForInitialToken) { + if (!started.compareAndSet(false, true)) { + throw new AuthXException("Token manager already started!"); + } + this.listener = listener; + RenewalTask currentTask = renewalScheduler.scheduleNext(0); + if (blockForInitialToken) { + try { + renewalScheduler.waitFor(currentTask); + } catch (Exception e) { + throw prepareToPropogate(e); + } + } + } + + /** + * This method is called by the renewal scheduler + * Dispatches a request to the identity provider asynchronously, with a timeout for execution, and returns the Token if successfully acquired. + * If the request fails, it retries until the max number of retries is reached + * If the request fails after max number of retries, it throws an exception + * When a new Token is received, it schedules the next renewal with calculating the delay in respect to the new token. + * Scheduling cycle only ends under two conditions: + * 1. TokenManager is stopped + * 2. Token renewal fails for max number of retries + * @return + */ + protected Token renewToken() { + if (stopped) { + return null; + } + Token newToken = null; + try { + currentToken = newToken = dispatcher.requestTokenAsync().getResult(); + long delay = calculateRenewalDelay(newToken.getExpiresAt(), newToken.getReceivedAt()); + renewalScheduler.scheduleNext(delay); + listener.onTokenRenewed(newToken); + return newToken; + } catch (Exception e) { + if (numberOfRetries.getAndIncrement() < maxRetries) { + renewalScheduler.scheduleNext(retryDelay); + } else { + RuntimeException propogateExc = prepareToPropogate(e); + listener.onError(propogateExc); + throw propogateExc; + } + } + return null; + } + + private RuntimeException prepareToPropogate(Exception e) { + Throwable unwrapped = e; + if (unwrapped instanceof ExecutionException) { + unwrapped = e.getCause(); + } + if (unwrapped instanceof TokenRequestException) { + return (RuntimeException) unwrapped; + } + return new TokenRequestException(unwrapped, dispatcher.getError()); + } + + public TokenManagerConfig getConfig() { + return tokenManagerConfig; + } + + public Token getCurrentToken() { + return currentToken; + } + + public void stop() { + stopped = true; + renewalScheduler.stop(); + dispatcher.stop(); + } + + /** + * This method calculates the duration we need to wait for requesting the next token. + * Token acquisition and authentication with the new token should be completed before the current token expires. + * We define a time window between a point in time(T) and the token's expiration time. Let's call this the "renewal zone." + * The goal is to trigger a token renewal anytime soon within this renewal zone. + * This is necessary to avoid situations where connections are running on an AUTH where token has already expired. + * The method calculates the delay to the renewal zone based on two different strategies and returns the minimum of them. + * If the calculated delay is somehow negative, it returns 0 to trigger the renewal immediately. + * @param expireDate + * @param issueDate + * @return + */ + public long calculateRenewalDelay(long expireDate, long issueDate) { + long ttlLowerRefresh = ttlForLowerRefresh(expireDate); + long ttlRatioRefresh = ttlForRatioRefresh(expireDate, issueDate); + long delay = Math.min(ttlLowerRefresh, ttlRatioRefresh); + + return delay < 0 ? 0 : delay; + } + + /** + * This method calculates TTL to renewal zone based on a minimum duration to token expiration. + * The suggested renewal zone here starts LowerRefreshBoundMillis(given in configuration) before the token expiration time. + * As example we have 1 hour left to token expiration and LowerRefreshBoundMillis is configured as 10 minutes, renewal zone will start in 50 minutes from now. + * This is the return value, 50 minutes TTL to renewal zone. + * @param expireDate + * @return + */ + protected long ttlForLowerRefresh(long expireDate) { + long startOfRenewalZone = expireDate - tokenManagerConfig.getLowerRefreshBoundMillis(); + return startOfRenewalZone - System.currentTimeMillis(); // TTL to renewal zone + } + + /** + * This method calculates TTL to renewal zone based on a ratio. + * The ExpirationRefreshRatio value in config, indicates the ratio of intended usage of token's total lifetime between receive/issue time and expiration time. + * The suggested renewal zone here starts right after the token completes the given ratio of its total valid duration starting from issue time till expiration. + * As example we have a token with 1 hour total valid time and it already reach to half life, which lefts 30 minutes to token expiration. + * ExpirationRefreshRatio is configured as 0.8, means token will be in use for first 48 minutes of its valid duration. It needs to renew 12 minutes before the expiration. + * This makes it is 30 minutes left to expiration and 18 minutes left to renewal zone. + * Return value is 18 minutes TTL to renewal zone. + * @param expireDate + * @param issueDate + * @return + */ + protected long ttlForRatioRefresh(long expireDate, long issueDate) { + long totalLifetime = expireDate - issueDate; + long intendedUsageDuration = (long) (totalLifetime + * tokenManagerConfig.getExpirationRefreshRatio()); + long startOfRenewalZone = issueDate + intendedUsageDuration; + return startOfRenewalZone - System.currentTimeMillis(); // TTL to renewal zone + } +} diff --git a/core/src/main/java/redis/clients/authentication/core/TokenManagerConfig.java b/core/src/main/java/redis/clients/authentication/core/TokenManagerConfig.java new file mode 100644 index 0000000..907a61a --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/TokenManagerConfig.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +/** + * Token manager example configuration. + */ +public class TokenManagerConfig { + + private final float expirationRefreshRatio; + private final int lowerRefreshBoundMillis; + private final int tokenRequestExecTimeoutInMs; + private final RetryPolicy retryPolicy; + + public static class RetryPolicy { + private final int maxAttempts; + private final int delayInMs; + + public RetryPolicy(int maxAttempts, int delayInMs) { + this.maxAttempts = maxAttempts; + this.delayInMs = delayInMs; + } + + public int getMaxAttempts() { + return maxAttempts; + } + + public int getdelayInMs() { + return delayInMs; + } + + } + + public TokenManagerConfig(float expirationRefreshRatio, int lowerRefreshBoundMillis, + int tokenRequestExecTimeoutInMs, RetryPolicy retryPolicy) { + this.expirationRefreshRatio = expirationRefreshRatio; + this.lowerRefreshBoundMillis = lowerRefreshBoundMillis; + this.tokenRequestExecTimeoutInMs = tokenRequestExecTimeoutInMs; + this.retryPolicy = retryPolicy; + } + + /** + * Represents the ratio of a token's lifetime at which a refresh should be triggered. + * For example, a value of 0.75 means the token should be refreshed when 75% of its + * lifetime has elapsed (or when 25% of its lifetime remains). + */ + public float getExpirationRefreshRatio() { + return expirationRefreshRatio; + } + + /** + * Represents the minimum time in milliseconds before token expiration to trigger a refresh, in milliseconds. + * This value sets a fixed lower bound for when a token refresh should occur, regardless + * of the token's total lifetime. + * If set to 0 there will be no lower bound and the refresh will be triggered based on the expirationRefreshRatio only. + */ + public int getLowerRefreshBoundMillis() { + return lowerRefreshBoundMillis; + } + + /** + * Represents the maximum time in milliseconds to wait for a token request to complete. + */ + public int getTokenRequestExecTimeoutInMs() { + return tokenRequestExecTimeoutInMs; + } + + /** + * Represents the retry policy for token requests. + */ + public RetryPolicy getRetryPolicy() { + return retryPolicy; + } +} diff --git a/core/src/main/java/redis/clients/authentication/core/TokenRequestException.java b/core/src/main/java/redis/clients/authentication/core/TokenRequestException.java new file mode 100644 index 0000000..14b9f13 --- /dev/null +++ b/core/src/main/java/redis/clients/authentication/core/TokenRequestException.java @@ -0,0 +1,31 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.core; + +public class TokenRequestException extends AuthXException { + + private static final String msg = "Token request/renewal failed!"; + private final Exception identityProviderFailedWith; + + public TokenRequestException(Throwable cause, Exception identityProviderFailedWith) { + super(getMessage(identityProviderFailedWith), cause); + this.identityProviderFailedWith = identityProviderFailedWith; + } + + public Exception getIdentityProviderFailedWith() { + return identityProviderFailedWith; + } + + private static String getMessage(Exception identityProviderFailedWith) { + if (identityProviderFailedWith == null) { + return msg; + } + return msg + " Identity provider request failed!" + + identityProviderFailedWith.getMessage(); + } + +} diff --git a/core/src/test/java/redis/clients/authentication/CoreAuthenticationUnitTests.java b/core/src/test/java/redis/clients/authentication/CoreAuthenticationUnitTests.java new file mode 100644 index 0000000..aacc2f0 --- /dev/null +++ b/core/src/test/java/redis/clients/authentication/CoreAuthenticationUnitTests.java @@ -0,0 +1,265 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication; + +import static org.mockito.Mockito.when; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.either; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.hamcrest.Matchers; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import redis.clients.authentication.core.IdentityProvider; +import redis.clients.authentication.core.SimpleToken; +import redis.clients.authentication.core.Token; +import redis.clients.authentication.core.TokenListener; +import redis.clients.authentication.core.TokenManager; +import redis.clients.authentication.core.TokenManagerConfig; +import redis.clients.authentication.core.TokenManagerConfig.RetryPolicy; +import redis.clients.authentication.core.TokenRequestException; + +import static org.awaitility.Awaitility.await; +import java.util.concurrent.TimeUnit; + +public class CoreAuthenticationUnitTests { + + public static class TokenManagerConfigWrapper extends TokenManagerConfig { + int lower; + float ratio; + + public TokenManagerConfigWrapper() { + super(0, 0, 0, null); + } + + @Override + public int getLowerRefreshBoundMillis() { + return lower; + } + + @Override + public float getExpirationRefreshRatio() { + return ratio; + } + + @Override + public RetryPolicy getRetryPolicy() { + return new RetryPolicy(1, 1); + } + } + + @Test + public void testCalculateRenewalDelay() { + long delay = 0; + long duration = 0; + long issueDate; + long expireDate; + + TokenManagerConfigWrapper config = new TokenManagerConfigWrapper(); + TokenManager manager = new TokenManager(() -> null, config); + + duration = 5000; + config.lower = 2000; + config.ratio = 0.5F; + issueDate = System.currentTimeMillis(); + expireDate = issueDate + duration; + + delay = manager.calculateRenewalDelay(expireDate, issueDate); + + assertThat(delay, Matchers + .greaterThanOrEqualTo(Math.min(duration - config.lower, (long) (duration * config.ratio)))); + + duration = 10000; + config.lower = 8000; + config.ratio = 0.2F; + issueDate = System.currentTimeMillis(); + expireDate = issueDate + duration; + + delay = manager.calculateRenewalDelay(expireDate, issueDate); + + assertThat(delay, Matchers + .greaterThanOrEqualTo(Math.min(duration - config.lower, (long) (duration * config.ratio)))); + + duration = 10000; + config.lower = 10000; + config.ratio = 0.2F; + issueDate = System.currentTimeMillis(); + expireDate = issueDate + duration; + + delay = manager.calculateRenewalDelay(expireDate, issueDate); + + assertEquals(0, delay); + + duration = 0; + config.lower = 5000; + config.ratio = 0.2F; + issueDate = System.currentTimeMillis(); + expireDate = issueDate + duration; + + delay = manager.calculateRenewalDelay(expireDate, issueDate); + + assertEquals(0, delay); + + duration = 10000; + config.lower = 1000; + config.ratio = 0.00001F; + issueDate = System.currentTimeMillis(); + expireDate = issueDate + duration; + + delay = manager.calculateRenewalDelay(expireDate, issueDate); + + assertEquals(0, delay); + + duration = 10000; + config.lower = 1000; + config.ratio = 0.0001F; + issueDate = System.currentTimeMillis(); + expireDate = issueDate + duration; + + delay = manager.calculateRenewalDelay(expireDate, issueDate); + + assertThat(delay, either(is(0L)).or(is(1L))); + } + + @Test + public void testTokenManagerStart() + throws InterruptedException, ExecutionException, TimeoutException { + + IdentityProvider identityProvider = () -> new SimpleToken("user1", "tokenVal", + System.currentTimeMillis() + 5 * 1000, System.currentTimeMillis(), null); + + TokenManager tokenManager = new TokenManager(identityProvider, + new TokenManagerConfig(0.7F, 200, 2000, new RetryPolicy(1, 1))); + + TokenListener listener = mock(TokenListener.class); + final Token[] tokenHolder = new Token[1]; + doAnswer(invocation -> { + Object[] args = invocation.getArguments(); + tokenHolder[0] = (Token) args[0]; + return null; + }).when(listener).onTokenRenewed(any()); + + tokenManager.start(listener, true); + assertEquals(tokenHolder[0].getValue(), "tokenVal"); + } + + @Test + public void testBlockForInitialToken() { + IdentityProvider identityProvider = () -> { + throw new RuntimeException("Test exception from identity provider!"); + }; + + TokenManager tokenManager = new TokenManager(identityProvider, + new TokenManagerConfig(0.7F, 200, 2000, new TokenManagerConfig.RetryPolicy(5, 100))); + + TokenRequestException e = assertThrows(TokenRequestException.class, + () -> tokenManager.start(mock(TokenListener.class), true)); + + assertEquals("Test exception from identity provider!", e.getCause().getMessage()); + } + + @Test + public void testNoBlockForInitialToken() + throws InterruptedException, ExecutionException, TimeoutException { + int numberOfRetries = 5; + CountDownLatch requesLatch = new CountDownLatch(numberOfRetries); + IdentityProvider identityProvider = () -> { + requesLatch.countDown(); + throw new RuntimeException("Test exception from identity provider!"); + }; + + TokenManager tokenManager = new TokenManager(identityProvider, new TokenManagerConfig(0.7F, 200, + 2000, new TokenManagerConfig.RetryPolicy(numberOfRetries - 1, 100))); + + TokenListener listener = mock(TokenListener.class); + tokenManager.start(listener, false); + + requesLatch.await(); + verify(listener, atLeastOnce()).onError(any()); + verify(listener, never()).onTokenRenewed(any()); + } + + @Test + public void testTokenManagerWithFailingTokenRequest() + throws InterruptedException, ExecutionException, TimeoutException { + int numberOfRetries = 5; + CountDownLatch requesLatch = new CountDownLatch(numberOfRetries); + + IdentityProvider identityProvider = mock(IdentityProvider.class); + when(identityProvider.requestToken()).thenAnswer(invocation -> { + requesLatch.countDown(); + if (requesLatch.getCount() > 0) { + throw new RuntimeException("Test exception from identity provider!"); + } + return new SimpleToken("user1", "tokenValX", System.currentTimeMillis() + 50 * 1000, + System.currentTimeMillis(), null); + }); + + ArgumentCaptor argument = ArgumentCaptor.forClass(Token.class); + + TokenManager tokenManager = new TokenManager(identityProvider, new TokenManagerConfig(0.7F, 200, + 2000, new TokenManagerConfig.RetryPolicy(numberOfRetries - 1, 100))); + + TokenListener listener = mock(TokenListener.class); + tokenManager.start(listener, false); + requesLatch.await(); + verify(identityProvider, times(numberOfRetries)).requestToken(); + verify(listener, never()).onError(any()); + verify(listener).onTokenRenewed(argument.capture()); + assertEquals("tokenValX", argument.getValue().getValue()); + } + + @Test + public void testTokenManagerWithHangingTokenRequest() + throws InterruptedException, ExecutionException, TimeoutException { + int delayDuration = 200; + int executionTimeout = 100; + int tokenLifetime = 50 * 1000; + int numberOfRetries = 5; + CountDownLatch requesLatch = new CountDownLatch(numberOfRetries); + + IdentityProvider identityProvider = () -> { + requesLatch.countDown(); + if (requesLatch.getCount() > 0) { + delay(delayDuration); + } + return new SimpleToken("user1", "tokenValX", System.currentTimeMillis() + tokenLifetime, + System.currentTimeMillis(), null); + }; + + TokenManager tokenManager = new TokenManager(identityProvider, new TokenManagerConfig(0.7F, 200, + executionTimeout, new TokenManagerConfig.RetryPolicy(numberOfRetries, 100))); + + TokenListener listener = mock(TokenListener.class); + tokenManager.start(listener, false); + requesLatch.await(); + verify(listener, never()).onError(any()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + verify(listener, times(1)).onTokenRenewed(any()); + }); + } + + private void delay(long durationInMs) { + try { + Thread.sleep(durationInMs); + } catch (InterruptedException e) { + } + } +} diff --git a/entraid/pom.xml b/entraid/pom.xml new file mode 100644 index 0000000..d08b08c --- /dev/null +++ b/entraid/pom.xml @@ -0,0 +1,282 @@ + + + + org.sonatype.oss + oss-parent + 7 + + + 4.0.0 + jar + redis.clients.authentication + redis-authx-entraid + 0.1.0-SNAPSHOT + Redis AuthX EntraID is an extension for Redis Java Clients to support token-based authentication with Microsoft EntraID. + https://github.com/redis/redis-authx-entraid + + + + Redis Authx Mailing List + redis_authx@googlegroups.com + + https://groups.google.com/group/redis_authx + + + + + + + MIT + https://github.com/redis/redis-authx-entraid/blob/master/LICENSE + repo + + + + + github + https://github.com/redis/redis-authx-entraid/issues + + + + scm:git:git@github.com:redis/redis-authx-entraid.git + scm:git:git@github.com:redis/redis-authx-entraid.git + scm:git:git@github.com:redis/redis-authx-entraid.git + entraid-0.1.0 + + + + github + redis.clients.authentication.entraid + 3.5.1 + + + + + + com.auth0 + java-jwt + 4.4.0 + + + redis.clients.authentication + redis-authx-core + 0.1.0-SNAPSHOT + + + com.microsoft.azure + msal4j + 1.17.2 + + + junit + junit + 4.13.2 + test + + + org.mockito + mockito-inline + 4.11.0 + test + + + org.hamcrest + hamcrest + 3.0 + test + + + org.awaitility + awaitility + 4.2.2 + test + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + + src/main/resources + true + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + report + test + + report + + + + + + maven-compiler-plugin + 3.13.0 + + 1.8 + 1.8 + + + + maven-surefire-plugin + ${maven.surefire.version} + + + ${redis-hosts} + + + **/examples/*Example.java + + + + + + maven-source-plugin + 3.3.1 + + true + + + + attach-sources + + jar + + + + + + maven-javadoc-plugin + 3.10.1 + + 8 + false + + + + + + attach-javadoc + + jar + + + + + + maven-release-plugin + 3.1.1 + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.7.0 + true + + ossrh + https://oss.sonatype.org/ + true + + + + com.googlecode.maven-java-formatter-plugin + maven-java-formatter-plugin + 0.4 + + ${project.basedir}/hbase-formatter.xml + + + + maven-jar-plugin + 3.4.2 + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + ${entraid.module.name} + + + + + + org.apache.felix + maven-bundle-plugin + 5.1.9 + + + bundle-manifest + process-classes + + manifest + + + + + + + + + release + + + + + maven-gpg-plugin + 3.2.7 + + + --pinentry-mode + loopback + + + + + sign-artifacts + verify + + sign + + + + + + + + + doctests + + + + maven-surefire-plugin + ${maven.surefire.version} + + **/examples/*Example.java + + + + + + + diff --git a/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDIdentityProvider.java b/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDIdentityProvider.java new file mode 100644 index 0000000..74c2b4e --- /dev/null +++ b/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDIdentityProvider.java @@ -0,0 +1,129 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.entraid; + +import java.net.MalformedURLException; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.function.Supplier; + +import com.microsoft.aad.msal4j.ClientCredentialFactory; +import com.microsoft.aad.msal4j.ClientCredentialParameters; +import com.microsoft.aad.msal4j.ConfidentialClientApplication; +import com.microsoft.aad.msal4j.IAuthenticationResult; +import com.microsoft.aad.msal4j.IClientCredential; +import com.microsoft.aad.msal4j.ManagedIdentityApplication; +import com.microsoft.aad.msal4j.ManagedIdentityParameters; +import redis.clients.authentication.core.IdentityProvider; +import redis.clients.authentication.core.Token; + +public final class EntraIDIdentityProvider implements IdentityProvider { + + private interface ClientApp { + public IAuthenticationResult request(); + } + + private interface ClientAppFactory { + public ClientApp create(); + } + + private ClientAppFactory clientAppFactory; + private ClientApp clientApp; + + public EntraIDIdentityProvider(ServicePrincipalInfo servicePrincipalInfo, Set scopes, + int timeout) { + + clientAppFactory = () -> { + return createConfidentialClientApp(servicePrincipalInfo, scopes, timeout); + }; + } + + private ClientApp createConfidentialClientApp(ServicePrincipalInfo servicePrincipalInfo, + Set scopes, int timeout) { + IClientCredential credential = getClientCredential(servicePrincipalInfo); + ConfidentialClientApplication app; + + try { + String authority = servicePrincipalInfo.getAuthority(); + authority = authority == null ? ConfidentialClientApplication.DEFAULT_AUTHORITY + : authority; + app = ConfidentialClientApplication + .builder(servicePrincipalInfo.getClientId(), credential).authority(authority) + .readTimeoutForDefaultHttpClient(timeout).build(); + } catch (MalformedURLException e) { + throw new RedisEntraIDException("Failed to init EntraID client!", e); + } + ClientCredentialParameters params = ClientCredentialParameters.builder(scopes) + .skipCache(true).build(); + + return () -> requestWithConfidentialClient(app, params); + } + + public EntraIDIdentityProvider(ManagedIdentityInfo info, Set scopes, int timeout) { + + clientAppFactory = () -> { + return createManagedIdentityApp(info, scopes, timeout); + }; + } + + private ClientApp createManagedIdentityApp(ManagedIdentityInfo info, Set scopes, + int timeout) { + ManagedIdentityApplication app = ManagedIdentityApplication.builder(info.getId()) + .readTimeoutForDefaultHttpClient(timeout).build(); + + ManagedIdentityParameters params = ManagedIdentityParameters + .builder(scopes.iterator().next()).forceRefresh(true).build(); + return () -> requestWithManagedIdentity(app, params); + } + + public EntraIDIdentityProvider( + Supplier customEntraIdAuthenticationSupplier) { + + clientAppFactory = () -> { + return () -> customEntraIdAuthenticationSupplier.get(); + }; + } + + private IClientCredential getClientCredential(ServicePrincipalInfo servicePrincipalInfo) { + switch (servicePrincipalInfo.getAccessWith()) { + case WithSecret: + return ClientCredentialFactory.createFromSecret(servicePrincipalInfo.getSecret()); + case WithCert: + return ClientCredentialFactory.createFromCertificate(servicePrincipalInfo.getKey(), + servicePrincipalInfo.getCert()); + default: + throw new RedisEntraIDException("Invalid ServicePrincipalAccess type!"); + } + } + + @Override + public Token requestToken() { + clientApp = clientApp == null ? clientAppFactory.create() : clientApp; + return new JWToken(clientApp.request().accessToken()); + } + + public IAuthenticationResult requestWithConfidentialClient(ConfidentialClientApplication app, + ClientCredentialParameters params) { + try { + Future tokenRequest = app.acquireToken(params); + return tokenRequest.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RedisEntraIDException("Failed to acquire token!", e); + } + } + + public IAuthenticationResult requestWithManagedIdentity(ManagedIdentityApplication app, + ManagedIdentityParameters params) { + try { + Future tokenRequest = app.acquireTokenForManagedIdentity(params); + return tokenRequest.get(); + } catch (Exception e) { + throw new RedisEntraIDException("Failed to acquire token!", e); + } + } +} diff --git a/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDIdentityProviderConfig.java b/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDIdentityProviderConfig.java new file mode 100644 index 0000000..a9b57f7 --- /dev/null +++ b/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDIdentityProviderConfig.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.entraid; + +import java.util.Set; +import java.util.function.Supplier; + +import com.microsoft.aad.msal4j.IAuthenticationResult; + +import redis.clients.authentication.core.IdentityProvider; +import redis.clients.authentication.core.IdentityProviderConfig; + +public final class EntraIDIdentityProviderConfig implements IdentityProviderConfig { + + private final Supplier providerSupplier; + + public EntraIDIdentityProviderConfig(ServicePrincipalInfo info, Set scopes, int timeout) { + providerSupplier = () -> new EntraIDIdentityProvider(info, scopes, timeout); + } + + public EntraIDIdentityProviderConfig(ManagedIdentityInfo info, Set scopes, int timeout) { + providerSupplier = () -> new EntraIDIdentityProvider(info, scopes, timeout); + } + + public EntraIDIdentityProviderConfig( + Supplier customEntraIdAuthenticationSupplier) { + providerSupplier = () -> new EntraIDIdentityProvider(customEntraIdAuthenticationSupplier); + } + + @Override + public IdentityProvider getProvider() { + IdentityProvider identityProvider = providerSupplier.get(); + return identityProvider; + } +} diff --git a/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDTokenAuthConfigBuilder.java b/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDTokenAuthConfigBuilder.java new file mode 100644 index 0000000..d74c002 --- /dev/null +++ b/entraid/src/main/java/redis/clients/authentication/entraid/EntraIDTokenAuthConfigBuilder.java @@ -0,0 +1,174 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.entraid; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Set; +import java.util.function.Supplier; + +import com.microsoft.aad.msal4j.IAuthenticationResult; + +import redis.clients.authentication.core.TokenAuthConfig; +import redis.clients.authentication.core.TokenManagerConfig; +import redis.clients.authentication.entraid.ManagedIdentityInfo.UserManagedIdentityType; +import redis.clients.authentication.entraid.ServicePrincipalInfo.ServicePrincipalAccess; + +public class EntraIDTokenAuthConfigBuilder + extends TokenAuthConfig.Builder implements AutoCloseable { + public static final float DEFAULT_EXPIRATION_REFRESH_RATIO = 0.75F; + public static final int DEFAULT_LOWER_REFRESH_BOUND_MILLIS = 2 * 60 * 1000; + public static final int DEFAULT_TOKEN_REQUEST_EXECUTION_TIMEOUT_IN_MS = 1000; + public static final int DEFAULT_MAX_ATTEMPTS_TO_RETRY = 5; + public static final int DEFAULT_DELAY_IN_MS_TO_RETRY = 100; + + private String clientId; + private String secret; + private PrivateKey key; + private X509Certificate cert; + private String authority; + private Set scopes; + private ServicePrincipalAccess accessWith; + private ManagedIdentityInfo mii; + private int tokenRequestExecTimeoutInMs; + private Supplier customEntraIdAuthenticationSupplier; + + public EntraIDTokenAuthConfigBuilder() { + this.expirationRefreshRatio(DEFAULT_EXPIRATION_REFRESH_RATIO) + .lowerRefreshBoundMillis(DEFAULT_LOWER_REFRESH_BOUND_MILLIS) + .tokenRequestExecTimeoutInMs(DEFAULT_TOKEN_REQUEST_EXECUTION_TIMEOUT_IN_MS) + .maxAttemptsToRetry(DEFAULT_MAX_ATTEMPTS_TO_RETRY) + .delayInMsToRetry(DEFAULT_DELAY_IN_MS_TO_RETRY); + } + + public EntraIDTokenAuthConfigBuilder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public EntraIDTokenAuthConfigBuilder secret(String secret) { + this.secret = secret; + this.accessWith = ServicePrincipalAccess.WithSecret; + return this; + } + + public EntraIDTokenAuthConfigBuilder key(PrivateKey key, X509Certificate cert) { + this.key = key; + this.cert = cert; + this.accessWith = ServicePrincipalAccess.WithCert; + return this; + } + + public EntraIDTokenAuthConfigBuilder authority(String authority) { + this.authority = authority; + return this; + } + + public EntraIDTokenAuthConfigBuilder systemAssignedManagedIdentity() { + mii = new ManagedIdentityInfo(); + return this; + } + + public EntraIDTokenAuthConfigBuilder userAssignedManagedIdentity( + UserManagedIdentityType userManagedType, String id) { + mii = new ManagedIdentityInfo(userManagedType, id); + return this; + } + + public EntraIDTokenAuthConfigBuilder customEntraIdAuthenticationSupplier( + Supplier customEntraIdAuthenticationSupplier) { + this.customEntraIdAuthenticationSupplier = customEntraIdAuthenticationSupplier; + return this; + } + + public EntraIDTokenAuthConfigBuilder scopes(Set scopes) { + this.scopes = scopes; + return this; + } + + @Override + public EntraIDTokenAuthConfigBuilder tokenRequestExecTimeoutInMs( + int tokenRequestExecTimeoutInMs) { + super.tokenRequestExecTimeoutInMs(tokenRequestExecTimeoutInMs); + this.tokenRequestExecTimeoutInMs = tokenRequestExecTimeoutInMs; + return this; + } + + public TokenAuthConfig build() { + ServicePrincipalInfo spi = null; + if (key != null || cert != null || secret != null) { + switch (accessWith) { + case WithCert: + spi = new ServicePrincipalInfo(clientId, key, cert, authority); + break; + case WithSecret: + spi = new ServicePrincipalInfo(clientId, secret, authority); + break; + } + } + if (spi != null && mii != null) { + throw new RedisEntraIDException( + "Cannot have both ServicePrincipal and ManagedIdentity!"); + } + if (this.customEntraIdAuthenticationSupplier != null && (spi != null || mii != null)) { + throw new RedisEntraIDException( + "Cannot have both customEntraIdAuthenticationSupplier and ServicePrincipal/ManagedIdentity!"); + } + if (spi != null) { + super.identityProviderConfig( + new EntraIDIdentityProviderConfig(spi, scopes, tokenRequestExecTimeoutInMs)); + } + if (mii != null) { + super.identityProviderConfig( + new EntraIDIdentityProviderConfig(mii, scopes, tokenRequestExecTimeoutInMs)); + } + if (customEntraIdAuthenticationSupplier != null) { + super.identityProviderConfig( + new EntraIDIdentityProviderConfig(customEntraIdAuthenticationSupplier)); + } + return super.build(); + } + + @Override + public void close() throws Exception { + clientId = null; + secret = null; + key = null; + cert = null; + authority = null; + scopes = null; + customEntraIdAuthenticationSupplier = null; + } + + public static EntraIDTokenAuthConfigBuilder builder() { + return new EntraIDTokenAuthConfigBuilder(); + } + + public static EntraIDTokenAuthConfigBuilder from(EntraIDTokenAuthConfigBuilder sample) { + TokenAuthConfig tokenAuthConfig = TokenAuthConfig.Builder.from(sample).build(); + TokenManagerConfig tokenManagerConfig = tokenAuthConfig.getTokenManagerConfig(); + + EntraIDTokenAuthConfigBuilder builder = (EntraIDTokenAuthConfigBuilder) new EntraIDTokenAuthConfigBuilder() + .expirationRefreshRatio(tokenManagerConfig.getExpirationRefreshRatio()) + .lowerRefreshBoundMillis(tokenManagerConfig.getLowerRefreshBoundMillis()) + .tokenRequestExecTimeoutInMs(tokenManagerConfig.getTokenRequestExecTimeoutInMs()) + .maxAttemptsToRetry(tokenManagerConfig.getRetryPolicy().getMaxAttempts()) + .delayInMsToRetry(tokenManagerConfig.getRetryPolicy().getdelayInMs()) + .identityProviderConfig(tokenAuthConfig.getIdentityProviderConfig()); + + builder.accessWith = sample.accessWith; + builder.authority = sample.authority; + builder.cert = sample.cert; + builder.clientId = sample.clientId; + builder.customEntraIdAuthenticationSupplier = sample.customEntraIdAuthenticationSupplier; + builder.key = sample.key; + builder.mii = sample.mii; + builder.scopes = sample.scopes; + builder.secret = sample.secret; + return builder; + } +} diff --git a/entraid/src/main/java/redis/clients/authentication/entraid/JWToken.java b/entraid/src/main/java/redis/clients/authentication/entraid/JWToken.java new file mode 100644 index 0000000..72cb576 --- /dev/null +++ b/entraid/src/main/java/redis/clients/authentication/entraid/JWToken.java @@ -0,0 +1,87 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.entraid; + +import java.util.function.BiFunction; + +import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.jwt.JWT; + +import redis.clients.authentication.core.Token; + +public class JWToken implements Token { + private final String user; + private final String token; + private final long expiresAt; + private final long receivedAt; + private final BiFunction, ?> claimQuery; + + public JWToken(String token) { + this.token = token; + DecodedJWT jwt = JWT.decode(token); + this.user = jwt.getClaim("oid").asString(); + this.expiresAt = jwt.getExpiresAt().getTime(); + this.receivedAt = System.currentTimeMillis(); + this.claimQuery = (key, clazz) -> jwt.getClaim(key).as(clazz); + } + + @Override + public boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + + @Override + public long ttl() { + return expiresAt - System.currentTimeMillis(); + } + + @Override + public String getUser() { + return user; + } + + @Override + public String getValue() { + return token; + } + + @Override + public long getExpiresAt() { + return expiresAt; + } + + @Override + public long getReceivedAt() { + return receivedAt; + } + + @Override + public String toString() { + return token; + } + + @Override + public int hashCode() { + return token.hashCode(); + } + + @Override + public boolean equals(Object that) { + if (this == that) return true; + if (that == null) return false; + if (that instanceof Token) { + return token.equals(((Token) that).getValue()); + } + return token.equals(that); + } + + @Override + public T tryGet(String key, Class clazz) { + return (T) claimQuery.apply(key, clazz); + } + +} diff --git a/entraid/src/main/java/redis/clients/authentication/entraid/ManagedIdentityInfo.java b/entraid/src/main/java/redis/clients/authentication/entraid/ManagedIdentityInfo.java new file mode 100644 index 0000000..c90173a --- /dev/null +++ b/entraid/src/main/java/redis/clients/authentication/entraid/ManagedIdentityInfo.java @@ -0,0 +1,56 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.entraid; + +import java.util.function.Function; + +import com.microsoft.aad.msal4j.ManagedIdentityId; + +public class ManagedIdentityInfo { + + public enum IdentityType { + SYSTEM_ASSIGNED, USER_ASSIGNED + } + + public enum UserManagedIdentityType { + CLIENT_ID(ManagedIdentityId::userAssignedClientId), + OBJECT_ID(ManagedIdentityId::userAssignedObjectId), + RESOURCE_ID(ManagedIdentityId::userAssignedResourceId); + + private final Function func; + + UserManagedIdentityType(Function func) { + this.func = func; + } + } + + private IdentityType type; + private UserManagedIdentityType userManagedIdentityType; + private String id; + + public ManagedIdentityInfo() { + type = IdentityType.SYSTEM_ASSIGNED; + } + + public ManagedIdentityInfo(UserManagedIdentityType userManagedType, String id) { + type = IdentityType.USER_ASSIGNED; + this.userManagedIdentityType = userManagedType; + this.id = id; + } + + public ManagedIdentityId getId() { + switch (type) { + case SYSTEM_ASSIGNED: + return ManagedIdentityId.systemAssigned(); + case USER_ASSIGNED: + return userManagedIdentityType.func.apply(id); + } + // this never happens + throw new UnsupportedOperationException( + "Operation not supported for the given identity type"); + } +} diff --git a/entraid/src/main/java/redis/clients/authentication/entraid/RedisEntraIDException.java b/entraid/src/main/java/redis/clients/authentication/entraid/RedisEntraIDException.java new file mode 100644 index 0000000..2248db1 --- /dev/null +++ b/entraid/src/main/java/redis/clients/authentication/entraid/RedisEntraIDException.java @@ -0,0 +1,20 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.entraid; + +import redis.clients.authentication.core.AuthXException; + +public class RedisEntraIDException extends AuthXException { + + public RedisEntraIDException(String message) { + super(message); + } + + public RedisEntraIDException(String message, Exception cause) { + super(message, cause); + } +} diff --git a/entraid/src/main/java/redis/clients/authentication/entraid/ServicePrincipalInfo.java b/entraid/src/main/java/redis/clients/authentication/entraid/ServicePrincipalInfo.java new file mode 100644 index 0000000..6840b01 --- /dev/null +++ b/entraid/src/main/java/redis/clients/authentication/entraid/ServicePrincipalInfo.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication.entraid; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +public class ServicePrincipalInfo { + + public enum ServicePrincipalAccess { + WithSecret, WithCert, + } + + private String clientId; + private String secret; + private PrivateKey key; + private X509Certificate cert; + private String authority; + private ServicePrincipalAccess accessWith; + + public ServicePrincipalInfo(String clientId, String secret, String authority) { + this.clientId = clientId; + this.secret = secret; + this.authority = authority; + accessWith = ServicePrincipalAccess.WithSecret; + } + + public ServicePrincipalInfo(String clientId, PrivateKey key, X509Certificate cert, + String authority) { + this.clientId = clientId; + this.key = key; + this.cert = cert; + this.authority = authority; + accessWith = ServicePrincipalAccess.WithCert; + } + + public String getClientId() { + return clientId; + } + + public String getSecret() { + return secret; + } + + public PrivateKey getKey() { + return key; + } + + public X509Certificate getCert() { + return cert; + } + + public String getAuthority() { + return authority; + } + + public ServicePrincipalAccess getAccessWith() { + return accessWith; + } +} diff --git a/entraid/src/test/java/redis/clients/authentication/EntraIDIntegrationTests.java b/entraid/src/test/java/redis/clients/authentication/EntraIDIntegrationTests.java new file mode 100644 index 0000000..ced17c3 --- /dev/null +++ b/entraid/src/test/java/redis/clients/authentication/EntraIDIntegrationTests.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication; + +import static org.junit.Assert.assertNotNull; + +import java.net.MalformedURLException; +import org.junit.Test; +import redis.clients.authentication.core.Token; +import redis.clients.authentication.entraid.EntraIDIdentityProvider; +import redis.clients.authentication.entraid.ServicePrincipalInfo; + +public class EntraIDIntegrationTests { + + @Test + public void requestTokenWithSecret() throws MalformedURLException { + TestContext testCtx = TestContext.DEFAULT; + ServicePrincipalInfo servicePrincipalInfo = new ServicePrincipalInfo( + testCtx.getClientId(), testCtx.getClientSecret(), + testCtx.getAuthority()); + Token token = new EntraIDIdentityProvider(servicePrincipalInfo, + testCtx.getRedisScopes(), 1000).requestToken(); + + assertNotNull(token.getValue()); + } + + @Test + public void requestTokenWithCert() throws MalformedURLException { + TestContext testCtx = TestContext.DEFAULT; + ServicePrincipalInfo servicePrincipalInfo = new ServicePrincipalInfo( + testCtx.getClientId(), testCtx.getPrivateKey(), testCtx.getCert(), + testCtx.getAuthority()); + Token token = new EntraIDIdentityProvider(servicePrincipalInfo, + testCtx.getRedisScopes(),1000).requestToken(); + assertNotNull(token.getValue()); + } + +} diff --git a/entraid/src/test/java/redis/clients/authentication/EntraIDUnitTests.java b/entraid/src/test/java/redis/clients/authentication/EntraIDUnitTests.java new file mode 100644 index 0000000..923080d --- /dev/null +++ b/entraid/src/test/java/redis/clients/authentication/EntraIDUnitTests.java @@ -0,0 +1,623 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.awaitility.Durations.*; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.hamcrest.Matchers.both; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import org.awaitility.Awaitility; +import org.awaitility.Durations; +import org.junit.Test; +import org.mockito.MockedConstruction; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.microsoft.aad.msal4j.ClientCredentialFactory; +import com.microsoft.aad.msal4j.ClientCredentialParameters; +import com.microsoft.aad.msal4j.ConfidentialClientApplication; +import com.microsoft.aad.msal4j.IAuthenticationResult; +import com.microsoft.aad.msal4j.IClientSecret; +import com.microsoft.aad.msal4j.ManagedIdentityId; + +import redis.clients.authentication.core.IdentityProvider; +import redis.clients.authentication.core.IdentityProviderConfig; +import redis.clients.authentication.core.SimpleToken; +import redis.clients.authentication.core.Token; +import redis.clients.authentication.core.TokenAuthConfig; +import redis.clients.authentication.core.TokenListener; +import redis.clients.authentication.core.TokenManager; +import redis.clients.authentication.core.TokenManagerConfig; +import redis.clients.authentication.core.TokenRequestException; +import redis.clients.authentication.entraid.EntraIDIdentityProvider; +import redis.clients.authentication.entraid.EntraIDTokenAuthConfigBuilder; +import redis.clients.authentication.entraid.JWToken; +import redis.clients.authentication.entraid.ManagedIdentityInfo; +import redis.clients.authentication.entraid.ServicePrincipalInfo; +import redis.clients.authentication.entraid.ManagedIdentityInfo.UserManagedIdentityType; + +public class EntraIDUnitTests { + + private static final float EXPIRATION_REFRESH_RATIO = 0.7F; + private static final int LOWER_REFRESH_BOUND_MILLIS = 200; + private static final int TOKEN_REQUEST_EXEC_TIMEOUT = 1000; + private static final int RETRY_POLICY_MAX_ATTEMPTS = 5; + private static final int RETRY_POLICY_DELAY = 100; + + private TokenManagerConfig tokenManagerConfig = new TokenManagerConfig(EXPIRATION_REFRESH_RATIO, + LOWER_REFRESH_BOUND_MILLIS, TOKEN_REQUEST_EXEC_TIMEOUT, + new TokenManagerConfig.RetryPolicy(RETRY_POLICY_MAX_ATTEMPTS, RETRY_POLICY_DELAY)); + + private static final String TOKEN_VALUE = "tokenVal"; + private static final long TOKEN_EXPIRATION_TIME = System.currentTimeMillis() + 60 * 60 * 1000; + private static final long TOKEN_ISSUE_TIME = System.currentTimeMillis(); + private static final String TOKEN_OID = "user1"; + + private Token simpleToken = new SimpleToken(TOKEN_OID, TOKEN_VALUE, TOKEN_EXPIRATION_TIME, + TOKEN_ISSUE_TIME, null); + + private TestContext testCtx = TestContext.DEFAULT; + + @Test + public void testConfigBuilder() { + String authority = "authority1"; + String clientId = "clientId1"; + String credential = "credential1"; + Set scopes = Collections.singleton("scope1"); + IdentityProviderConfig configWithSecret = EntraIDTokenAuthConfigBuilder.builder() + .authority(authority).clientId(clientId).secret(credential).scopes(scopes).build() + .getIdentityProviderConfig(); + assertNotNull(configWithSecret); + try (MockedConstruction mockedConstructor = mockConstruction( + EntraIDIdentityProvider.class, (mock, context) -> { + ServicePrincipalInfo info = (ServicePrincipalInfo) context.arguments().get(0); + assertEquals(clientId, info.getClientId()); + assertEquals(authority, info.getAuthority()); + assertEquals(credential, info.getSecret()); + assertEquals(scopes, context.arguments().get(1)); + + })) { + configWithSecret.getProvider(); + } + + IdentityProviderConfig configWithCert = EntraIDTokenAuthConfigBuilder.builder() + .authority(authority).clientId(clientId) + .key(testCtx.getPrivateKey(), testCtx.getCert()).scopes(scopes).build() + .getIdentityProviderConfig(); + assertNotNull(configWithCert); + try (MockedConstruction mockedConstructor = mockConstruction( + EntraIDIdentityProvider.class, (mock, context) -> { + ServicePrincipalInfo info = (ServicePrincipalInfo) context.arguments().get(0); + assertEquals(clientId, info.getClientId()); + assertEquals(authority, info.getAuthority()); + assertEquals(testCtx.getPrivateKey(), info.getKey()); + assertEquals(testCtx.getCert(), info.getCert()); + assertEquals(scopes, context.arguments().get(1)); + + })) { + configWithCert.getProvider(); + } + + IdentityProviderConfig configWithManagedId = EntraIDTokenAuthConfigBuilder.builder() + .systemAssignedManagedIdentity().scopes(scopes).build().getIdentityProviderConfig(); + assertNotNull(configWithManagedId); + try (MockedConstruction mockedConstructor = mockConstruction( + EntraIDIdentityProvider.class, (mock, context) -> { + ManagedIdentityInfo info = (ManagedIdentityInfo) context.arguments().get(0); + assertEquals(ManagedIdentityId.systemAssigned().getIdType(), + info.getId().getIdType()); + assertEquals(scopes, context.arguments().get(1)); + })) { + configWithManagedId.getProvider(); + } + } + + // T.1.2 + // Implement a stubbed IdentityProvider and verify that the TokenManager works normally and handles: + // network errors or other exceptions thrown from the IdentityProvider + // token parser errors + // e.g missing ttl in IDP’s response + // misformatted token + @Test + public void tokenRequestfailsWithException_fakeIdentityProviderTest() { + + IdentityProvider identityProvider = () -> { + throw new RuntimeException("Test exception from identity provider!"); + }; + + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + + TokenRequestException e = assertThrows(TokenRequestException.class, + () -> tokenManager.start(mock(TokenListener.class), true)); + + assertEquals("Test exception from identity provider!", e.getCause().getMessage()); + } + + // T.2.1 + // Verify that the auth extension can obtain an initial token in a blocking manner from the identity provider. + @Test + public void initialTokenAcquisitionTest() { + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean isTokenManagerStarted = new AtomicBoolean(false); + IdentityProvider identityProvider = () -> { + try { + latch.await(); + } catch (InterruptedException e) { + } + return simpleToken; + }; + + TokenManagerConfig tokenManagerConfig = new TokenManagerConfig(EXPIRATION_REFRESH_RATIO, + LOWER_REFRESH_BOUND_MILLIS, 60 * 60 * 1000, + this.tokenManagerConfig.getRetryPolicy()); + + TokenListener listener = mock(TokenListener.class); + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + Thread thread = new Thread(() -> { + try { + tokenManager.start(listener, true); + isTokenManagerStarted.set(true); + } catch (Exception e) { + } + }); + thread.start(); + + Awaitility.await().pollInterval(ONE_HUNDRED_MILLISECONDS).atMost(ONE_SECOND) + .until(() -> Thread.State.WAITING == thread.getState()); + + StackTraceElement[] stackTrace = thread.getStackTrace(); + assertEquals(false, isTokenManagerStarted.get()); + + for (int i = 0; i < stackTrace.length; i++) { + assertEquals(false, isTokenManagerStarted.get()); + + if (stackTrace[i].getMethodName().equals("waitFor") + && stackTrace[i + 1].getClassName().equals(TokenManager.class.getName()) + && stackTrace[i + 1].getMethodName().equals("start")) { + latch.countDown(); + break; + } + } + assertEquals(0, latch.getCount()); + Awaitility.await().pollInterval(ONE_HUNDRED_MILLISECONDS).atMost(ONE_SECOND) + .until(() -> isTokenManagerStarted.get()); + assertNotNull(tokenManager.getCurrentToken()); + } + + // T.2.1 + // Test the system's behavior when token acquisition fails initially but succeeds on retry. + @Test + public void tokenAcquisitionRetryTest() throws InterruptedException, TimeoutException { + AtomicInteger numberOfRetries = new AtomicInteger(0); + IdentityProvider identityProvider = () -> { + if (numberOfRetries.incrementAndGet() < 3) { + throw new RuntimeException("Test exception from identity provider!"); + } + return simpleToken; + + }; + + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + + tokenManager.start(mock(TokenListener.class), false); + + Awaitility.await().pollInterval(ONE_HUNDRED_MILLISECONDS).atMost(ONE_SECOND) + .until(() -> tokenManager.getCurrentToken() != null); + assertEquals(3, numberOfRetries.get()); + } + + // T.2.1 + // Ensure the system handles timeouts during token acquisition gracefully. + @Test + public void tokenAcquisitionTimeoutTest() throws InterruptedException, TimeoutException { + AtomicInteger numberOfRetries = new AtomicInteger(0); + + IdentityProvider identityProvider = () -> { + if (numberOfRetries.getAndIncrement() < 1) { + delay(TOKEN_REQUEST_EXEC_TIMEOUT * 2); + } + return simpleToken; + }; + + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + + long startTime = System.currentTimeMillis(); + tokenManager.start(mock(TokenListener.class), false); + + Awaitility.await().pollInterval(ONE_HUNDRED_MILLISECONDS).atMost(Durations.FIVE_SECONDS) + .until(() -> tokenManager.getCurrentToken() != null); + assertEquals(2, numberOfRetries.get()); + long totalTime = System.currentTimeMillis() - startTime; + assertThat(totalTime, lessThan(TOKEN_REQUEST_EXEC_TIMEOUT * 2L)); + } + + // T.2.2 + // Verify that tokens are automatically renewed in the background and listeners are notified asynchronously without user intervention. + @Test + public void backgroundTokenRenewalTest() throws InterruptedException, TimeoutException { + AtomicInteger numberOfTokens = new AtomicInteger(0); + + IdentityProvider identityProvider = () -> new SimpleToken(TOKEN_OID, TOKEN_VALUE, + System.currentTimeMillis() + 1000, System.currentTimeMillis(), null); + + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + TokenListener listener = new TokenListener() { + @Override + public void onTokenRenewed(Token token) { + numberOfTokens.incrementAndGet(); + } + + @Override + public void onError(Exception e) { + } + }; + + tokenManager.start(listener, false); + + Awaitility.await().pollInterval(ONE_HUNDRED_MILLISECONDS).atMost(Durations.TWO_SECONDS) + .until(() -> numberOfTokens.get(), is(2)); + } + + // T.2.2 + // Ensure the system propagates error during renewal back to the user + @Test + public void failedRenewalTest() { + AtomicInteger numberOfErrors = new AtomicInteger(0); + + IdentityProvider identityProvider = () -> { + throw new RuntimeException("Test exception from identity provider!"); + }; + + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + TokenListener listener = new TokenListener() { + @Override + public void onTokenRenewed(Token token) { + } + + @Override + public void onError(Exception e) { + numberOfErrors.incrementAndGet(); + } + }; + + tokenManager.start(listener, false); + + Awaitility.await().pollInterval(ONE_HUNDRED_MILLISECONDS).atMost(TWO_SECONDS) + .until(() -> numberOfErrors.get(), is(1)); + } + + // T.2.3 + // Test that token renewal can be triggered at a specified percentage of the token's lifetime. + // T.4.1 + // Verify that token renewal timing can be configured correctly. + @Test + public void customRenewalTimingTest() { + AtomicInteger numberOfTokens = new AtomicInteger(0); + AtomicInteger timeDiff = new AtomicInteger(0); + + IdentityProvider identityProvider = () -> new SimpleToken(TOKEN_OID, TOKEN_VALUE, + System.currentTimeMillis() + 1000, System.currentTimeMillis(), null); + + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + TokenListener listener = new TokenListener() { + Token lastToken = null; + + @Override + public void onTokenRenewed(Token token) { + numberOfTokens.incrementAndGet(); + if (lastToken != null) { + timeDiff.set((int) (token.getExpiresAt() - lastToken.getExpiresAt())); + } + lastToken = token; + + } + + @Override + public void onError(Exception e) { + } + }; + + tokenManager.start(listener, false); + + Integer lower = (int) (tokenManagerConfig.getExpirationRefreshRatio() * 1000 - 10); + Integer upper = (int) (tokenManagerConfig.getExpirationRefreshRatio() * 1000 + 10); + Awaitility.await().pollInterval(ONE_HUNDRED_MILLISECONDS).atMost(Durations.TWO_SECONDS) + .until(() -> numberOfTokens.get(), is(2)); + assertThat((Integer) timeDiff.get(), + both(greaterThanOrEqualTo(lower)).and(lessThanOrEqualTo(upper))); + } + + // T.2.3 + // Verify behavior with edge case renewal timing configurations (e.g., very low or high percentages). + // T.4.1 + // Verify that token renewal timing can be configured correctly. + @Test + public void highPercentage_edgeCaseRenewalTimingTest() { + List tokens = new ArrayList(); + int validDurationInMs = 1000; + + IdentityProvider identityProvider = () -> new SimpleToken(TOKEN_OID, TOKEN_VALUE, + System.currentTimeMillis() + validDurationInMs, System.currentTimeMillis(), null); + + TokenManagerConfig tokenManagerConfig = new TokenManagerConfig(0.99F, 0, + TOKEN_REQUEST_EXEC_TIMEOUT, + new TokenManagerConfig.RetryPolicy(RETRY_POLICY_MAX_ATTEMPTS, RETRY_POLICY_DELAY)); + + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + TokenListener listener = new TokenListener() { + + @Override + public void onTokenRenewed(Token token) { + tokens.add(token); + } + + @Override + public void onError(Exception e) { + } + }; + + tokenManager.start(listener, false); + + Awaitility.await().pollInterval(Duration.ofMillis(10)).atMost(Durations.TWO_SECONDS) + .until(() -> tokens.size(), is(2)); + + Token initialToken = tokens.get(0); + Token secondToken = tokens.get(1); + Long renewalWindowStart = initialToken.getReceivedAt() + + (long) (validDurationInMs * tokenManagerConfig.getExpirationRefreshRatio()); + Long renewalWindowEnd = initialToken.getExpiresAt(); + assertThat((Long) secondToken.getReceivedAt(), + both(greaterThanOrEqualTo(renewalWindowStart)) + .and(lessThanOrEqualTo(renewalWindowEnd))); + } + + // T.2.3 + // Verify behavior with edge case renewal timing configurations (e.g., very low or high percentages). + // T.4.1 + // Verify that token renewal timing can be configured correctly. + @Test + public void lowPercentage_edgeCaseRenewalTimingTest() { + List tokens = new ArrayList(); + int validDurationInMs = 1000; + + IdentityProvider identityProvider = () -> new SimpleToken(TOKEN_OID, TOKEN_VALUE, + System.currentTimeMillis() + validDurationInMs, System.currentTimeMillis(), null); + + TokenManagerConfig tokenManagerConfig = new TokenManagerConfig(0.01F, 0, + TOKEN_REQUEST_EXEC_TIMEOUT, + new TokenManagerConfig.RetryPolicy(RETRY_POLICY_MAX_ATTEMPTS, RETRY_POLICY_DELAY)); + + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + TokenListener listener = new TokenListener() { + + @Override + public void onTokenRenewed(Token token) { + tokens.add(token); + } + + @Override + public void onError(Exception e) { + } + }; + + tokenManager.start(listener, false); + + Awaitility.await().pollInterval(ONE_MILLISECOND).atMost(Durations.TWO_SECONDS) + .until(() -> tokens.size(), is(2)); + + Token initialToken = tokens.get(0); + Token secondToken = tokens.get(1); + Long renewalWindowStart = initialToken.getReceivedAt() + + (long) (validDurationInMs * tokenManagerConfig.getExpirationRefreshRatio()); + Long renewalWindowEnd = initialToken.getExpiresAt(); + assertThat((Long) secondToken.getReceivedAt(), + both(greaterThanOrEqualTo(renewalWindowStart)) + .and(lessThanOrEqualTo(renewalWindowEnd))); + } + + // T.2.4 + // Confirm that the system correctly identifies expired tokens. (isExpired works) + @Test + public void expiredTokenCheckTest() { + String token = JWT.create().withExpiresAt(new Date(System.currentTimeMillis() - 1000)) + .withClaim("oid", "user1").sign(Algorithm.none()); + assertTrue(new JWToken(token).isExpired()); + + token = JWT.create().withExpiresAt(new Date(System.currentTimeMillis() + 1000)) + .withClaim("oid", "user1").sign(Algorithm.none()); + + assertFalse(new JWToken(token).isExpired()); + } + + // T.2.5 + // Verify that tokens are correctly parsed (e.g. with string value, expiresAt, and receivedAt attributes) + @Test + public void tokenParserTest() { + long aSecondBefore = (System.currentTimeMillis() / 1000) * 1000 - 1000; + + String token = JWT.create().withExpiresAt(new Date(aSecondBefore)).withClaim("oid", "user1") + .sign(Algorithm.none()); + Token actual = new JWToken(token); + + assertEquals(token, actual.getValue()); + assertEquals(aSecondBefore, actual.getExpiresAt()); + assertThat((Long) (System.currentTimeMillis() - actual.getReceivedAt()), + lessThanOrEqualTo((Long) 10L)); + } + + // T.2.5 + // Ensure that token objects are immutable and cannot be modified after creation. + @Test + public void tokenImmutabilityTest() { + // TODO : what is expected exatcly ? + } + + // T.3.1 + // Verify that the most recent valid token is correctly cached and that the cache is initially empty + @Test + public void tokenCachingTest() { + AtomicInteger numberOfRetries = new AtomicInteger(0); + IdentityProvider identityProvider = () -> { + if (numberOfRetries.getAndIncrement() < 1) { + delay(TOKEN_REQUEST_EXEC_TIMEOUT); + } + return simpleToken; + }; + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + assertNull(tokenManager.getCurrentToken()); + tokenManager.start(mock(TokenListener.class), true); + assertNotNull(tokenManager.getCurrentToken()); + } + + // T.3.1 + // Ensure the token cache is updated when a new token is acquired or renewed. + @Test + public void cacheUpdateOnRenewalTest() { + + AtomicInteger numberOfTokens = new AtomicInteger(0); + IdentityProvider identityProvider = () -> { + return new SimpleToken("user1", "" + numberOfTokens.incrementAndGet(), + System.currentTimeMillis() + 500, System.currentTimeMillis(), null); + }; + TokenManager tokenManager = new TokenManager(identityProvider, tokenManagerConfig); + assertNull(tokenManager.getCurrentToken()); + tokenManager.start(mock(TokenListener.class), true); + assertNotNull(tokenManager.getCurrentToken()); + assertEquals("1", tokenManager.getCurrentToken().getValue()); + Awaitility.await().pollInterval(ONE_HUNDRED_MILLISECONDS).atMost(TWO_SECONDS) + .until(() -> tokenManager.getCurrentToken().getValue(), is("2")); + } + + // T.4.1 + // Verify that token renewal timing can be configured correctly. + @Test + public void renewalTimingConfigTest() { + float refreshRatio = 0.71F; + int delayInMsToRetry = 201; + int lowerRefreshBoundMillis = 301; + int maxAttemptsToRetry = 6; + int tokenRequestExecTimeoutInMs = 401; + TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder() + .expirationRefreshRatio(refreshRatio).delayInMsToRetry(delayInMsToRetry) + .lowerRefreshBoundMillis(lowerRefreshBoundMillis) + .maxAttemptsToRetry(maxAttemptsToRetry) + .tokenRequestExecTimeoutInMs(tokenRequestExecTimeoutInMs).build(); + TokenManagerConfig config = tokenAuthConfig.getTokenManagerConfig(); + assertEquals(refreshRatio, config.getExpirationRefreshRatio(), 0.00000001F); + assertEquals(delayInMsToRetry, config.getRetryPolicy().getdelayInMs()); + assertEquals(lowerRefreshBoundMillis, config.getLowerRefreshBoundMillis()); + assertEquals(maxAttemptsToRetry, config.getRetryPolicy().getMaxAttempts()); + assertEquals(tokenRequestExecTimeoutInMs, config.getTokenRequestExecTimeoutInMs()); + } + + // T.4.2 + // Verify that Azure AD-specific parameters can be configured correctly. + @Test + public void withKeyCert_azureADConfigTest() { + PrivateKey key = mock(PrivateKey.class); + X509Certificate cert = mock(X509Certificate.class); + Set scopes = Collections.singleton("testScope"); + try (MockedConstruction mockedConstructor = mockConstruction( + EntraIDIdentityProvider.class, (mock, context) -> { + ServicePrincipalInfo info = (ServicePrincipalInfo) (context.arguments().get(0)); + assertEquals("testClientId", info.getClientId()); + assertEquals("testAuthority", info.getAuthority()); + assertEquals(key, info.getKey()); + assertEquals(cert, info.getCert()); + assertEquals(scopes, context.arguments().get(1)); + })) { + TokenAuthConfig config = EntraIDTokenAuthConfigBuilder.builder() + .clientId("testClientId").authority("testAuthority").key(key, cert) + .scopes(scopes).build(); + config.getIdentityProviderConfig().getProvider(); + } + } + + // T.4.2 + // Verify that Azure AD-specific parameters can be configured correctly. + @Test + public void withUserAssignedManagedId_azureADConfigTest() { + Set scopes = Collections.singleton("testScope"); + try (MockedConstruction mockedConstructor = mockConstruction( + EntraIDIdentityProvider.class, (mock, context) -> { + ManagedIdentityInfo info = (ManagedIdentityInfo) (context.arguments().get(0)); + assertEquals("CLIENT_ID", ((Object) info.getId().getIdType()).toString()); + assertEquals("testUserManagedId", info.getId().getUserAssignedId()); + assertEquals(scopes, context.arguments().get(1)); + })) { + TokenAuthConfig config = EntraIDTokenAuthConfigBuilder.builder() + .clientId("testClientId").authority("testAuthority") + .userAssignedManagedIdentity(UserManagedIdentityType.CLIENT_ID, + "testUserManagedId") + .scopes(scopes).build(); + config.getIdentityProviderConfig().getProvider(); + } + } + + // T.4.2 + // Test configuration of custom identity provider parameters. + @Test + public void customProviderConfigTest() { + IClientSecret secret = ClientCredentialFactory.createFromSecret(testCtx.getClientSecret()); + // Choose and configure any type of app with any parameters as needed + ConfidentialClientApplication app = ConfidentialClientApplication + .builder(testCtx.getClientId(), secret).build(); + // Customize credential parameters as needed + ClientCredentialParameters parameters = ClientCredentialParameters + .builder(Collections.singleton("testScope")).build(); + Supplier supplier = () -> { + try { + return app.acquireToken(parameters).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + }; + + try (MockedConstruction mockedConstructor = mockConstruction( + EntraIDIdentityProvider.class, (mock, context) -> { + assertEquals(supplier, context.arguments().get(0)); + })) { + TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder() + .customEntraIdAuthenticationSupplier(supplier).build(); + tokenAuthConfig.getIdentityProviderConfig().getProvider(); + } + } + + private void delay(long durationInMs) { + try { + Thread.sleep(durationInMs); + } catch (InterruptedException e) { + } + } +} diff --git a/entraid/src/test/java/redis/clients/authentication/TestContext.java b/entraid/src/test/java/redis/clients/authentication/TestContext.java new file mode 100644 index 0000000..79c7829 --- /dev/null +++ b/entraid/src/test/java/redis/clients/authentication/TestContext.java @@ -0,0 +1,128 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package redis.clients.authentication; + +import java.util.Arrays; +import java.util.Base64; +import java.util.HashSet; +import java.util.Set; +import java.io.ByteArrayInputStream; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; + +public class TestContext { + + private static final String AZURE_CLIENT_ID = "AZURE_CLIENT_ID"; + private static final String AZURE_AUTHORITY = "AZURE_AUTHORITY"; + private static final String AZURE_CLIENT_SECRET = "AZURE_CLIENT_SECRET"; + private static final String AZURE_PRIVATE_KEY = "AZURE_PRIVATE_KEY"; + private static final String AZURE_CERT = "AZURE_CERT"; + private static final String AZURE_REDIS_SCOPES = "AZURE_REDIS_SCOPES"; + private static final String AZURE_USER_ASSIGNED_MANAGED_IDENTITY_CLIENT_ID = "AZURE_USER_ASSIGNED_MANAGED_IDENTITY_CLIENT_ID"; + + private String clientId; + private String authority; + private String clientSecret; + private PrivateKey privateKey; + private X509Certificate cert; + private Set redisScopes; + private String userAssignedManagedIdentityClientId; + + public static final TestContext DEFAULT = new TestContext(); + + private TestContext() { + + this.clientId = System.getenv(AZURE_CLIENT_ID); + this.authority = System.getenv(AZURE_AUTHORITY); + this.clientSecret = System.getenv(AZURE_CLIENT_SECRET); + this.userAssignedManagedIdentityClientId = System + .getenv(AZURE_USER_ASSIGNED_MANAGED_IDENTITY_CLIENT_ID); + } + + public TestContext(String clientId, String authority, String clientSecret, + Set redisScopes) { + this.clientId = clientId; + this.authority = authority; + this.clientSecret = clientSecret; + this.redisScopes = redisScopes; + } + + public String getClientId() { + return clientId; + } + + public String getAuthority() { + return authority; + } + + public String getClientSecret() { + return clientSecret; + } + + public PrivateKey getPrivateKey() { + if (privateKey == null) { + this.privateKey = getPrivateKey(System.getenv(AZURE_PRIVATE_KEY)); + } + return privateKey; + } + + public X509Certificate getCert() { + if (cert == null) { + this.cert = getCert(System.getenv(AZURE_CERT)); + } + return cert; + } + + public Set getRedisScopes() { + if (redisScopes == null) { + String redisScopesEnv = System.getenv(AZURE_REDIS_SCOPES); + this.redisScopes = new HashSet<>(Arrays.asList(redisScopesEnv.split(";"))); + } + return redisScopes; + } + + public String getUserAssignedManagedIdentityClientId() { + return userAssignedManagedIdentityClientId; + } + + private PrivateKey getPrivateKey(String privateKey) { + try { + // Decode the base64 encoded key into a byte array + byte[] decodedKey = Base64.getDecoder().decode(privateKey); + + // Generate the private key from the decoded byte array using PKCS8EncodedKeySpec + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decodedKey); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); // Use the correct algorithm (e.g., "RSA", "EC", "DSA") + PrivateKey key = keyFactory.generatePrivate(keySpec); + return key; + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private X509Certificate getCert(String cert) { + try { + // Convert the Base64 encoded string into a byte array + byte[] encoded = java.util.Base64.getDecoder().decode(cert); + + // Create a CertificateFactory for X.509 certificates + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + + // Generate the certificate from the byte array + X509Certificate certificate = (X509Certificate) certificateFactory + .generateCertificate(new ByteArrayInputStream(encoded)); + return certificate; + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } +} diff --git a/hbase-formatter.xml b/hbase-formatter.xml new file mode 100644 index 0000000..cf59372 --- /dev/null +++ b/hbase-formatter.xml @@ -0,0 +1,291 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0927c3f --- /dev/null +++ b/pom.xml @@ -0,0 +1,21 @@ + + + + org.sonatype.oss + oss-parent + 7 + + + 4.0.0 + pom + redis.clients.authentication + redis-authx + 0.1.0 + redis-authx + + + core + entraid + + +