From b9c9230d4c9a8b27dbffee5cbc81bfd6e9cac66f Mon Sep 17 00:00:00 2001 From: Lydon da Rocha <69146037+TheLydonKing@users.noreply.github.com> Date: Wed, 10 Jan 2024 16:17:03 +0200 Subject: [PATCH] Feature/11 client library for convenience (#81) * Create Initial Library * Basic Example using the Lib * Added features to example * Added Documentation to Library * Minor Refactoring * Initial Changes to separate out example and add library to nexus * Add License and let Project Build * Add Lib from Nexus to Application.scala * Changed from external to internal dependency * Remove Credentials * Split Public Key from Token retrieval * Rename Retrieval Service Classes * Initial SBT Refactoring * Remove Decoder Class in order to use NimbusDecoder Directly * Minor Refactoring from comments * Added DecoderProvider * Add Tests for ClaimsParser * Fix Tests * Fix Tests decoder version * Minor Refactoring * Minor ReadMe Refactoring * #11 change suggestions (#84) * Refactoring and Verificator Tests * Readme Additions * Added New Test Cases * #11 change suggestions 2 (#85) * Addition to Developer list --------- Co-authored-by: Daniel K --- README.md | 5 +- build.sbt | 31 ++- clientLibrary/README.md | 68 +++++++ .../AccessTokenVerificator.scala | 59 ++++++ .../authorization/ClaimsParser.scala | 185 ++++++++++++++++++ .../authorization/JwtDecoderProvider.scala | 68 +++++++ .../RefreshTokenVerificator.scala | 59 ++++++ .../exceptions/LsJwtException.scala | 22 +++ .../client/PublicKeyRetrievalClient.scala | 78 ++++++++ .../publicKeyRetrieval/model/PublicKey.scala | 25 +++ .../client/TokenRetrievalClient.scala | 155 +++++++++++++++ .../tokenRetrieval/model/Token.scala | 29 +++ .../AccessTokenVerificatorTest.scala | 68 +++++++ .../authorization/ClaimsParserTest.scala | 183 +++++++++++++++++ .../authorization/FakeTokens.scala | 122 ++++++++++++ .../RefreshTokenVerificatorTest.scala | 68 +++++++ .../main/resources/example.application.yaml | 4 + .../co/absa/clientexample/Application.scala | 119 +++++++++++ .../clientexample/config/ConfigProvider.scala | 39 ++++ .../clientexample/config/ExampleConfig.scala | 21 ++ project/Dependencies.scala | 28 ++- publish.sbt | 17 ++ .../loginsvc/rest/service/JWTService.scala | 6 +- 23 files changed, 1448 insertions(+), 11 deletions(-) create mode 100644 clientLibrary/README.md create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/AccessTokenVerificator.scala create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/ClaimsParser.scala create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/JwtDecoderProvider.scala create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/RefreshTokenVerificator.scala create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/exceptions/LsJwtException.scala create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/publicKeyRetrieval/client/PublicKeyRetrievalClient.scala create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/publicKeyRetrieval/model/PublicKey.scala create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/client/TokenRetrievalClient.scala create mode 100644 clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/model/Token.scala create mode 100644 clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/AccessTokenVerificatorTest.scala create mode 100644 clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/ClaimsParserTest.scala create mode 100644 clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/FakeTokens.scala create mode 100644 clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/RefreshTokenVerificatorTest.scala create mode 100644 examples/src/main/resources/example.application.yaml create mode 100644 examples/src/main/scala/za/co/absa/clientexample/Application.scala create mode 100644 examples/src/main/scala/za/co/absa/clientexample/config/ConfigProvider.scala create mode 100644 examples/src/main/scala/za/co/absa/clientexample/config/ExampleConfig.scala diff --git a/README.md b/README.md index 1c46cbd..0548b30 100644 --- a/README.md +++ b/README.md @@ -253,4 +253,7 @@ If you wish to generate an accurate `git.properties file`, you can do so in 2 wa once this is done, running or debugging the test will generate a `git.properties` file to be used for the info endpoint. This requires Git to be installed and available on the host. -The example `git.properties` file provided may be edited manually if the git generation is functioning incorrectly. \ No newline at end of file +The example `git.properties` file provided may be edited manually if the git generation is functioning incorrectly. + +## Client Library +See Readme in [clientLibrary](clientLibrary/README.md) module. \ No newline at end of file diff --git a/build.sbt b/build.sbt index c24cf63..f0271f9 100644 --- a/build.sbt +++ b/build.sbt @@ -16,8 +16,7 @@ import Dependencies._ import com.github.sbt.jacoco.report.JacocoReportSettings -ThisBuild / organization := "za.co.absa" -ThisBuild / name := "login-service" +ThisBuild / organization := "za.co.absa.login-service" lazy val scala212 = "2.12.17" @@ -29,15 +28,16 @@ lazy val commonJacocoReportSettings: JacocoReportSettings = JacocoReportSettings ) lazy val commonJacocoExcludes: Seq[String] = Seq( - "za.co.absa.loginsvc.rest.Application*" -// "za.co.absa.loginsvc.rest.config.BaseConfig" // class only + "za.co.absa.loginsvc.rest.Application*" + // "za.co.absa.loginsvc.rest.config.BaseConfig" // class only ) lazy val parent = (project in file(".")) - .aggregate(service) + .aggregate(service, clientLibrary, examples) .settings( name := "login-service", javacOptions ++= Seq("-source", "1.8", "-target", "1.8", "-Xlint"), + // No need to publish the aggregation [empty] artifact publish / skip := true ) @@ -46,10 +46,25 @@ lazy val service = project // no need to define file, because path is same as va name := "login-service-service", libraryDependencies ++= serviceDependencies, webappWebInfClasses := true, - inheritJarManifest := true - ) - .settings( + inheritJarManifest := true, + // No need to publish the service + publish / skip := true, jacocoReportSettings := commonJacocoReportSettings.withTitle(s"login-service:service Jacoco Report - scala:${scalaVersion.value}"), jacocoExcludes := commonJacocoExcludes ).enablePlugins(TomcatPlugin) .enablePlugins(AutomateHeaderPlugin) + +lazy val clientLibrary = project // no need to define file, because path is same as val name + .settings( + name := "login-service-client-library", + libraryDependencies ++= clientLibDependencies + ).enablePlugins(AutomateHeaderPlugin) + +lazy val examples = project // no need to define file, because path is same as val name + .settings( + name := "login-service-examples", + libraryDependencies ++= exampleDependencies, + // No need to publish the example artifact + publish / skip := true + ).enablePlugins(AutomateHeaderPlugin) + .dependsOn(clientLibrary) \ No newline at end of file diff --git a/clientLibrary/README.md b/clientLibrary/README.md new file mode 100644 index 0000000..92ad6d9 --- /dev/null +++ b/clientLibrary/README.md @@ -0,0 +1,68 @@ +# Login-service client library + +This library provides client-functionality for the login-service. + +## Usage + +Include the library in your project: +### Maven + +` + za.co.absa + login-service-client-library_2.12 + $VERSION +` + +### SBT + +`libraryDependencies += "za.co.absa" % "login-service-client-library_2.12" % "0.1.0-SNAPSHOT"` + +See the [examples](examples) +for a more detailed view of how to use the library. + +## Public key retrieval + +The library provides a `PublicKeyRetrievalClient` class that can be used to retrieve the public key to verify tokens' signatures. +Public Key is available without authorization so just the relevant host needs to be provided. Public Key is available as a `String` and as a JWKS. + +## Token retrieval + +The library provides a `TokenRetrievalClient` class that can be used to retrieve access and refresh tokens. +Refresh and Access Keys require authorization. Basic Auth is used for the initial retrieval so a valid username and password is required. +Please see the [login-service documentation](README.md) for more information on what a valid username and password is. +Refresh token from initial retrieval is used to refresh the access token. + +## Creating and Using a JWT Decoder + +The User can create and use the `org.springframework.security.oauth2.jwt.NimbusJwtDecoder` by utilizing the 'JwtDecoderProvider' object. +This allows the user to create the decoder from a publicKey object, String or URL. + +## Parsing and using Claims + +`AccessTokenClaimsParser` object is used to parse decoded Access Token claims. +`RefreshTokenClaimsParser` object is used to parse decoded Refresh Token claims. +Both are used to extract the claims from the respective decoded jwt which can be used to check and verify the token claims. + +For example, one may check an access token for the `groups` claim to indicate what a user may or may not do. + +## Token Verification + +The TokenVerifiers are used to verify if a token is valid. +The `AccessTokenVerifier` is used to verify an access token. +The `RefreshTokenVerifier` is used to verify a refresh token. + +These verifiers check if the token has the following: +1. A valid signature +2. The token is not expired +3. The token is of the correct type + +It will Return a JWT Object with claims that can be read if the token is valid. + +## Example Code + +An example of how to use the library can be found in the [examples](examples) folder. +The example makes use of a [configuration file](examples/src/main/resources/example.application.yaml) to provide the necessary configuration to the library. + +Configurations required are: +1. `host` - the url of the login-service (Including Port if required) +2. 'refresh-period' - the period between refreshing the public-key used for verification. This Parameter is optional. \ No newline at end of file diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/AccessTokenVerificator.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/AccessTokenVerificator.scala new file mode 100644 index 0000000..11cabe8 --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/AccessTokenVerificator.scala @@ -0,0 +1,59 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.authorization + +import org.springframework.security.oauth2.jwt.{Jwt, JwtDecoder, NimbusJwtDecoder} +import za.co.absa.loginclient.exceptions.LsJwtException +import za.co.absa.loginclient.tokenRetrieval.model.AccessToken + +import java.time.Instant + +case class AccessTokenVerificator( + decoder: JwtDecoder +) { + + def decodeAndVerifyAccessToken(accessToken: AccessToken): Jwt = { + + val jwt = try decoder.decode(accessToken.token) + catch { + case e: Throwable => throw LsJwtException(s"Access Token Decoding Failed: ${e.getMessage}", e) + } + + val verificationSuccess = verifyDecodedAccessToken(jwt) + + if (!verificationSuccess){ + throw LsJwtException("Access Token Verification Failed") + } + + jwt + } + + /** + * Verifies that the JWT is a valid access token. + * Checks that the token is not expired and that the type is access. + * + * @param jwt The JWT to parse. + * @return True if the JWT is a valid access token, false otherwise. + */ + private[authorization] def verifyDecodedAccessToken(jwt: Jwt): Boolean = { + val exp = AccessTokenClaimsParser.getExpiration(jwt) + val notExpired = exp.isAfter(Instant.now()) + + val isAccessType = AccessTokenClaimsParser.isAccessTokenType(jwt) + notExpired && isAccessType + } +} diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/ClaimsParser.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/ClaimsParser.scala new file mode 100644 index 0000000..9c162a3 --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/ClaimsParser.scala @@ -0,0 +1,185 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.authorization + +import org.springframework.security.oauth2.jwt.Jwt +import za.co.absa.loginclient.exceptions.LsJwtException + +import java.time.Instant +import java.util +import scala.collection.JavaConverters._ + +trait ClaimsParser { + + /** + * Returns a list of all the claim keys in the JWT. + * + * @param jwt The JWT to parse. + * @return A list of all the claim keys in the JWT. + */ + def listClaimKeys(jwt: Jwt): List[String] = { + jwt.getClaims.keySet().asScala.toList + } + + /** + * Returns the value of the claim with the given key. + * + * @param jwt The JWT to parse. + * @param claimKey The key of the claim to retrieve. + * @return The value of the claim with the given key. + */ + def getClaim(jwt: Jwt, claimKey: String): Option[Any] = { + Option(jwt.getClaim(claimKey)) + } + + /** + * Returns a map of all the claims in the JWT. + * + * @param jwt The JWT to parse. + * @return A map of all the claims in the JWT. + */ + def getAllClaims(jwt: Jwt): Map[String, Any] = { + jwt.getClaims.asScala.toMap + } + + /** + * Returns the username of the user that the JWT was issued to. + * + * @param jwt The JWT to parse. + * @return The username of the user that the JWT was issued to. + */ + def getSubject(jwt: Jwt): String = { + getClaim(jwt, "sub") match { + case Some(username) => username.toString + case None => throw LsJwtException("Subject not found") + } + } + + /** + * Returns the expiry time of the JWT. + * + * @param jwt The JWT to parse. + * @return The expiry time of the JWT. + */ + def getExpiration(jwt: Jwt): Instant = { + getClaim(jwt, "exp") match { + case Some(expiration) => Instant.parse(expiration.toString) + case None => throw LsJwtException("Expiration not found") + } + } + + /** + * Returns the issue time of the JWT. + * + * @param jwt The JWT to parse. + * @return The issue time of the JWT. + */ + def getIssueTime(jwt: Jwt): Instant = { + getClaim(jwt, "iat") match { + case Some(issueTime) => Instant.parse(issueTime.toString) + case None => throw LsJwtException("Issue time not found") + } + } + + /** + * Returns the type of JWT. Can be either an access or refresh token. + * + * @param jwt The JWT to parse. + * @return The type of JWT as a String. + */ + def getTokenType(jwt: Jwt): String = { + getClaim(jwt, "type") match { + case Some(tokenType) => tokenType.toString + case None => throw LsJwtException("Token type not found") + } + } +} + +/** + * This object is used to parse Access Token claims. + */ + +object AccessTokenClaimsParser extends ClaimsParser { + + /** + * Returns the list of groups of the user that the JWT was issued to. + * + * @param jwt The JWT to parse. + * @return The List of groups of the user that the JWT was issued to. + */ + def getGroups(jwt: Jwt): List[String] = { + getClaim(jwt, "groups") match { + case Some(groups) => groups.asInstanceOf[util.ArrayList[String]].asScala.toList + case None => List() + } + } + + /** + * Returns the email of the user that the JWT was issued to. + * @param jwt The JWT to parse. + * @return The email of the user that the JWT was issued to. + */ + def getEmail(jwt: Jwt): Option[String] = { + getClaim(jwt, "email") match { + case Some(email) => Some(email.toString) + case None => None + } + } + + /** + * Returns the display name of the user that the JWT was issued to. + * @param jwt The JWT to parse. + * @return The display name of the user that the JWT was issued to. + */ + def getDisplayName(jwt: Jwt): Option[String] = { + getClaim(jwt, "displayname") match { + case Some(displayName) => Some(displayName.toString) + case None => None + } + } + + /** + * Checks the type of JWT to be 'access' + * + * @param jwt The JWT to parse. + * @return true if type=access was found, false otherwise + * @throws LsJwtException if `type` claim was not found at all + + */ + + def isAccessTokenType(jwt: Jwt): Boolean = { + getTokenType(jwt) == "access" + } +} + +/** + * This object is used to parse Refresh Token claims. + */ +object RefreshTokenClaimsParser extends ClaimsParser { + + /** + * Checks the type of JWT to be 'refresh' + * + * @param jwt The JWT to parse. + * @return true if type=refresh was found, false otherwise + * @throws LsJwtException if `type` claim was not found at all + */ + + def isRefreshTokenType(jwt: Jwt): Boolean = { + getTokenType(jwt) == "refresh" + } +} \ No newline at end of file diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/JwtDecoderProvider.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/JwtDecoderProvider.scala new file mode 100644 index 0000000..3103ab1 --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/JwtDecoderProvider.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.authorization + +import org.springframework.security.oauth2.jwt.{Jwt, JwtDecoder, NimbusJwtDecoder} +import za.co.absa.loginclient.publicKeyRetrieval.model.PublicKey +import za.co.absa.loginclient.tokenRetrieval.model.{AccessToken, RefreshToken} + +import java.security.KeyFactory +import java.security.interfaces.RSAPublicKey +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 + +object JwtDecoderProvider { + + /** + * Retrieves a JwtDecoder instance using the provided public key string. + * This method creates and returns a JwtDecoder instance using the specified public key string. + * Currently implemented by NimbusJwtDecoder. + * + * @param publicKeyString The string representation of the public key used for JWT decoding. + * @return A JwtDecoder instance initialized with the provided public key string. + */ + def getDecoderFromPublicKeyString(publicKeyString: String): JwtDecoder = { + val publicKeyBytes = Base64.getDecoder.decode(publicKeyString) + val publicKeySpec = new X509EncodedKeySpec(publicKeyBytes) + val encodedPublicKey = KeyFactory.getInstance("RSA").generatePublic(publicKeySpec).asInstanceOf[RSAPublicKey] + NimbusJwtDecoder.withPublicKey(encodedPublicKey).build() + } + + /** + * Retrieves a JwtDecoder instance using the provided PublicKey object. + * This method creates and returns a JwtDecoder instance using the specified PublicKey object. + * Currently implemented by NimbusJwtDecoder. + * + * @param publicKey The PublicKey object used for JWT decoding. + * @return A JwtDecoder instance initialized with the provided PublicKey object. + */ + def getDecoderFromPublicKey(publicKey: PublicKey): JwtDecoder = { + getDecoderFromPublicKeyString(publicKey.token) + } + + /** + * Retrieves a JwtDecoder instance by fetching the public key from the specified host url. + * This method creates and returns a JwtDecoder instance with the retrieved public key. + * Currently implemented by NimbusJwtDecoder.withJwkSetUri(JWKS_PATH). + * + * @param host The URL from which the public key will be fetched. + * @return A JwtDecoder instance initialized with the public key fetched from the URL. + */ + def getDecoderFromURL(host: String): JwtDecoder = { + NimbusJwtDecoder.withJwkSetUri(s"$host/token/public-key-jwks").build() + } +} diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/RefreshTokenVerificator.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/RefreshTokenVerificator.scala new file mode 100644 index 0000000..e75ff4d --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/authorization/RefreshTokenVerificator.scala @@ -0,0 +1,59 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.authorization + +import org.springframework.security.oauth2.jwt.{Jwt, JwtDecoder} +import za.co.absa.loginclient.exceptions.LsJwtException +import za.co.absa.loginclient.tokenRetrieval.model.RefreshToken + +import java.time.Instant + +case class RefreshTokenVerificator( +decoder: JwtDecoder +) { + + def decodeAndVerifyRefreshToken(refreshToken: RefreshToken): Jwt = { + + val jwt = try decoder.decode(refreshToken.token) + catch { + case e: Throwable => throw LsJwtException(s"Refresh Token Decoding Failed: ${e.getMessage}", e) + } + + val verificationSuccess = verifyDecodedRefreshToken(jwt) + + if (!verificationSuccess) { + throw LsJwtException("Refresh Token Verification Failed") + } + + jwt + } + + /** + * Verifies that the JWT is a refresh token. + * Checks that the token is not expired and that the type is refresh. + * + * @param jwt The JWT to parse. + * @return True if the JWT is a valid refresh token, false otherwise. + */ + private[authorization] def verifyDecodedRefreshToken(jwt: Jwt): Boolean = { + val exp = RefreshTokenClaimsParser.getExpiration(jwt) + val notExpired = exp.isAfter(Instant.now()) + + val isRefreshType = RefreshTokenClaimsParser.isRefreshTokenType(jwt) + notExpired && isRefreshType + } +} diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/exceptions/LsJwtException.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/exceptions/LsJwtException.scala new file mode 100644 index 0000000..e98f5a5 --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/exceptions/LsJwtException.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.exceptions + +case class LsJwtException( + message: String, + cause: Throwable = null +) extends Exception(message, cause) diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/publicKeyRetrieval/client/PublicKeyRetrievalClient.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/publicKeyRetrieval/client/PublicKeyRetrievalClient.scala new file mode 100644 index 0000000..e8e8943 --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/publicKeyRetrieval/client/PublicKeyRetrievalClient.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.publicKeyRetrieval.client + +import com.google.gson.JsonParser +import com.nimbusds.jose.jwk.JWK +import org.slf4j.{Logger, LoggerFactory} +import org.springframework.web.client.RestTemplate +import za.co.absa.loginclient.publicKeyRetrieval.model.PublicKey + +/** + * This class is used to retrieve the public key from the issuer. + * public key is available in either a string or a JWK format. + * @param host The issuer host. + */ + +case class PublicKeyRetrievalClient(host: String) { + + private val logger: Logger = LoggerFactory.getLogger(this.getClass) + + /** + * Retrieves the public key from the login service as a PublicKey object. + * This method fetches the public key used for JWT verification and returns it as a PublicKey object. + * Key is available as a string within the object + * + * @return A PublicKey object representing the public key retrieved from the login service. + */ + def getPublicKey: PublicKey = { + val issuerUri = s"$host/token/public-key" + val jsonString = fetchToken(issuerUri) + val token = JsonParser.parseString(jsonString).getAsJsonObject.get("key").getAsString + PublicKey(token) + } + + /** + * Retrieves the public key from the login service in JWK (JSON Web Key) format. + * This method fetches the public key used for JWT verification and returns it in JWK format. + * + * @return A String containing the public key in JWK (JSON Web Key) format retrieved from the login service. + */ + def getPublicKeyJwk: JWK = { + val issuerUri = s"$host/token/public-key-jwks" + val jsonString = fetchToken(issuerUri) + val jwkString = JsonParser.parseString(jsonString).getAsJsonObject.get("key").getAsString + JWK.parse(jwkString) + } + + private def fetchToken(issuerUri: String): String = { + + logger.info(s"Fetching token from $issuerUri") + + val restTemplate = new RestTemplate() + try { + val response = restTemplate.getForEntity(issuerUri, classOf[String]) + logger.info("Successfully fetched token") + response.getBody + } + catch { + case e: Throwable => + logger.error(s"Error occurred retrieving and decoding Token from $issuerUri", e) + throw e + } + } +} diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/publicKeyRetrieval/model/PublicKey.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/publicKeyRetrieval/model/PublicKey.scala new file mode 100644 index 0000000..983fb2d --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/publicKeyRetrieval/model/PublicKey.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.publicKeyRetrieval.model + +/** + * This class represents a public key. + * This is used to store the public key string + * @param token The public key string. + */ + +case class PublicKey(token: String) diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/client/TokenRetrievalClient.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/client/TokenRetrievalClient.scala new file mode 100644 index 0000000..0518828 --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/client/TokenRetrievalClient.scala @@ -0,0 +1,155 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.tokenRetrieval.client + +import com.google.gson.{JsonObject, JsonParser} +import org.slf4j.{Logger, LoggerFactory} +import org.springframework.http.{HttpEntity, HttpHeaders, HttpMethod, MediaType, ResponseEntity} +import org.springframework.web.client.RestTemplate +import za.co.absa.loginclient.tokenRetrieval.model.{AccessToken, RefreshToken} + +import java.net.URLEncoder +import java.util.Collections + +/** + * This class is used to retrieve tokens from the login service. + * Refresh and Access Keys require authorization. Basic Auth is used for the initial retrieval. + * Refresh token from initial retrieval is used to refresh the access token. + * @param host The host of the login service. + */ + +case class TokenRetrievalClient(host: String) { + + private val logger: Logger = LoggerFactory.getLogger(this.getClass) + + /** + * This method requests an access token (JWT) from the login service using the specified username and password. + * This Token is used to access resources which utilize the login Service for authentication. + * + * @param username The username used for authentication. + * @param password The password associated with the provided username. + * @param groups An optional list of PAM groups. If provided, only JWTs associated with these groups are returned if the user belongs to them. + * @return An AccessToken object representing the retrieved access token (JWT) from the login service. + */ + def fetchAccessToken(username: String, password: String, groups: List[String] = List.empty): AccessToken = { + fetchAccessAndRefreshToken(username, password, groups)._1 + } + + /** + * This method requests a refresh token from the login service using the specified username and password. + * This token may be used to acquire a new access token (JWT) when the current access token expires. + * + * @param username The username used for authentication. + * @param password The password associated with the provided username. + * @return A RefreshToken object representing the retrieved refresh token from the login service. + */ + def fetchRefreshToken(username: String, password: String): RefreshToken = { + fetchAccessAndRefreshToken(username, password)._2 + } + + /** + * Fetches both an access token and a refresh token from the login service using the provided username, password, and optional groups. + * This method requests both an access token and a refresh token (JWTs) from the login service using the specified username and password. + * Additionally, it allows specifying optional groups that act as filters for the JWT, returning only the JWTs associated with the provided groups if the user belongs to them. + * + * @param username The username used for authentication. + * @param password The password associated with the provided username. + * @param groups An optional list of PAM groups. If provided, only JWTs associated with these groups are returned if the user belongs to them. + * @return A tuple containing the AccessToken and RefreshToken objects representing the retrieved access and refresh tokens (JWTs) from the login service. + */ + def fetchAccessAndRefreshToken(username: String, password: String, groups: List[String] = List.empty): (AccessToken, RefreshToken) = { + + val issuerUri = if(groups.nonEmpty) { + val commaSeparatedString = groups.mkString(",") + val urlEncodedGroups = URLEncoder.encode(commaSeparatedString, "UTF-8") + s"$host/token/generate?group-prefixes=$urlEncodedGroups" + } else s"$host/token/generate" + + val jsonString = fetchToken(issuerUri, username, password) + val jsonObject = JsonParser.parseString(jsonString).getAsJsonObject + val accessToken = jsonObject.get("token").getAsString + val refreshToken = jsonObject.get("refresh").getAsString + (AccessToken(accessToken), RefreshToken(refreshToken)) + } + + def refreshAccessToken(accessToken: AccessToken, refreshToken: RefreshToken): (AccessToken, RefreshToken) = { + val issuerUri = s"$host/token/refresh" + + logger.info(s"Refreshing Access token from $issuerUri") + + val jsonPayload: JsonObject = new JsonObject() + jsonPayload.addProperty("token", accessToken.token) + jsonPayload.addProperty("refresh", refreshToken.token) + + val headers: HttpHeaders = new HttpHeaders() + headers.setContentType(MediaType.APPLICATION_JSON) + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)) + + val requestEntity = new HttpEntity[String] (jsonPayload.toString, headers) + + val restTemplate: RestTemplate = new RestTemplate() + + try { + val response: ResponseEntity[String] = restTemplate.exchange( + issuerUri, + HttpMethod.POST, + requestEntity, + classOf[String] + ) + val jsonObject = JsonParser.parseString(response.getBody).getAsJsonObject + logger.info("Successfully refreshed token") + ( + AccessToken(jsonObject.get("token").getAsString), + RefreshToken(jsonObject.get("refresh").getAsString) + ) + } + catch { + case e: Throwable => + logger.error(s"Error occurred refreshing and decoding Token from $issuerUri", e) + throw e + } + } + + private def fetchToken(issuerUri: String, username: String, password: String): String = { + + logger.info(s"Fetching token from $issuerUri for user $username") + + val headers = new HttpHeaders() + val base64Credentials = java.util.Base64.getEncoder.encodeToString(s"$username:$password".getBytes) + headers.set("Authorization", s"Basic $base64Credentials") + + val requestEntity = new HttpEntity[String](null, headers) + + val restTemplate = new RestTemplate() + + try { + val response: ResponseEntity[String] = restTemplate.exchange( + issuerUri, + HttpMethod.POST, + requestEntity, + classOf[String] + ) + logger.info("Successfully fetched token") + response.getBody + } + catch { + case e: Throwable => + logger.error(s"Error occurred retrieving and decoding Token from $issuerUri", e) + throw e + } + } +} diff --git a/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/model/Token.scala b/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/model/Token.scala new file mode 100644 index 0000000..b6753ca --- /dev/null +++ b/clientLibrary/src/main/scala/za/co/absa/loginclient/tokenRetrieval/model/Token.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.tokenRetrieval.model + +/** + * This class represents a token. + * This is used to store the token string + * @param token The token string. + */ + +case class AccessToken(token: String) extends Token + +case class RefreshToken(token: String) extends Token + +trait Token {def token: String} diff --git a/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/AccessTokenVerificatorTest.scala b/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/AccessTokenVerificatorTest.scala new file mode 100644 index 0000000..000abd2 --- /dev/null +++ b/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/AccessTokenVerificatorTest.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.authorization + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import za.co.absa.loginclient.exceptions.LsJwtException +import za.co.absa.loginclient.tokenRetrieval.model.AccessToken + +import java.util.Base64 + +class AccessTokenVerificatorTest extends AnyFlatSpec with Matchers{ + + private val publicKeyString = Base64.getEncoder.encodeToString(FakeTokens.keys.getPublic.getEncoded) + private val decoder = JwtDecoderProvider.getDecoderFromPublicKeyString(publicKeyString) + private val accessTokenVerificator = AccessTokenVerificator(decoder) + + "Access Token" should "pass decoding" in { + val token = AccessToken(FakeTokens.validAccessToken) + accessTokenVerificator.decodeAndVerifyAccessToken(token) + } + + "Incorrectly structured Access Token" should "fail decoding" in { + val token = AccessToken(FakeTokens.missingSubjectToken) + val exception = the[LsJwtException] thrownBy { + accessTokenVerificator.decodeAndVerifyAccessToken(token) + } + exception.getMessage shouldBe "Access Token Decoding Failed: An error occurred while attempting to decode the Jwt: Malformed payload" + } + + "Expired Access Token" should "fail decoding" in { + val token = AccessToken(FakeTokens.invalidExpirationAccessToken) + val exception = the[LsJwtException] thrownBy { + accessTokenVerificator.decodeAndVerifyAccessToken(token) + } + exception.getMessage shouldBe "Access Token Decoding Failed: An error occurred while attempting to decode the Jwt: expiresAt must be after issuedAt" + } + + "Incorrectly signed Access Token" should "fail decoding" in { + val token = AccessToken(FakeTokens.invalidSignatureAccessToken) + val exception = the[LsJwtException] thrownBy { + accessTokenVerificator.decodeAndVerifyAccessToken(token) + } + exception.getMessage shouldBe "Access Token Decoding Failed: An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature" + } + + "Access Token with missing Type" should "return an exception" in { + val token = AccessToken(FakeTokens.invalidTypeToken) + val exception = the[LsJwtException] thrownBy { + accessTokenVerificator.decodeAndVerifyAccessToken(token) + } + exception.getMessage shouldBe "Access Token Verification Failed" + } +} diff --git a/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/ClaimsParserTest.scala b/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/ClaimsParserTest.scala new file mode 100644 index 0000000..ea9a000 --- /dev/null +++ b/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/ClaimsParserTest.scala @@ -0,0 +1,183 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.authorization + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import za.co.absa.loginclient.exceptions.LsJwtException + +import java.time.Instant +import java.util.Base64 + +object ClaimsParserTest { + val publicKeyString = Base64.getEncoder.encodeToString(FakeTokens.keys.getPublic.getEncoded) + val decoder = JwtDecoderProvider.getDecoderFromPublicKeyString(publicKeyString) + + val missingTokenTypeJwt = decoder.decode(FakeTokens.missingTokenTypeToken) + val missingAllClaimsButSubjectJwt = decoder.decode(FakeTokens.missingAllClaimsButSubjectToken) + +} + + +class AccessClaimsParserTest extends AnyFlatSpec with Matchers { + + private val accessJwt = ClaimsParserTest.decoder.decode(FakeTokens.validAccessToken) + import ClaimsParserTest._ + + "Access token" should "return a subject that equals 'testUser'" in { + val subject = AccessTokenClaimsParser.getSubject(accessJwt) + assert(subject.equals(FakeTokens.subject)) + } + + it should "return a list of groups that contain 'group1', 'group2')" in { + val groups = AccessTokenClaimsParser.getGroups(accessJwt) + assert(groups == List("group1", "group2")) + } + + it should "return an empty groups even if groups claim is not present at all" in { + val groups = AccessTokenClaimsParser.getGroups(missingAllClaimsButSubjectJwt) // does not throw + assert(groups == List.empty) + } + + it should "return a expiration date that should be within an hour of testing" in { + val exp = AccessTokenClaimsParser.getExpiration(accessJwt) + val check = FakeTokens.validExpiration.toInstant + assert(exp.equals(Instant.ofEpochSecond(check.getEpochSecond))) + } + + it should "return an exception for missing expiration" in { + val exception = the[LsJwtException] thrownBy { + AccessTokenClaimsParser.getExpiration(missingAllClaimsButSubjectJwt) + } + exception.getMessage shouldBe "Expiration not found" + } + + it should "return the set time of issue" in { + val issueTime = AccessTokenClaimsParser.getIssueTime(accessJwt) + val check = FakeTokens.issuedAt.toInstant + assert(issueTime.equals(Instant.ofEpochSecond(check.getEpochSecond))) + } + + it should "return an exception for missing issuedAt" in { + val exception = the[LsJwtException] thrownBy { + AccessTokenClaimsParser.getIssueTime(missingAllClaimsButSubjectJwt) + } + exception.getMessage shouldBe "Issue time not found" + } + + it should "return the email address" in { + val email = AccessTokenClaimsParser.getEmail(accessJwt) + assert(email.get.equals(FakeTokens.email)) + } + + it should "return an empty email if email claim is not present at all" in { + val email = AccessTokenClaimsParser.getEmail(missingAllClaimsButSubjectJwt) // does not throw + email shouldBe None + } + + it should "return the display name" in { + val displayName = AccessTokenClaimsParser.getDisplayName(accessJwt) + assert(displayName.get.equals(FakeTokens.displayName)) + } + + it should "return an empty display name if displayname claim is not present at all" in { + val dn = AccessTokenClaimsParser.getDisplayName(missingAllClaimsButSubjectJwt) // does not throw + dn shouldBe None + } + + it should "return the token type" in { + val tokenType = AccessTokenClaimsParser.getTokenType(accessJwt) + assert(tokenType.equals("access")) + } + + it should "return an exception for missing token type" in { + val exception = the[LsJwtException] thrownBy { + AccessTokenClaimsParser.getTokenType(missingTokenTypeJwt) + } + exception.getMessage shouldBe "Token type not found" + } + + it should "check access token type" in { + assert(AccessTokenClaimsParser.isAccessTokenType(accessJwt)) + } + + it should "return an exception for missing token type 2" in { + val exception = the[LsJwtException] thrownBy { + AccessTokenClaimsParser.isAccessTokenType(missingTokenTypeJwt) + } + exception.getMessage shouldBe "Token type not found" + } +} +class RefreshClaimsParserTest extends AnyFlatSpec with Matchers { + + private val refreshJwt = ClaimsParserTest.decoder.decode(FakeTokens.validRefreshToken) + import ClaimsParserTest._ + + "Refresh Token" should "return a subject that equals 'testUser'" in { + val sub = RefreshTokenClaimsParser.getSubject(refreshJwt) + assert(sub.equals(FakeTokens.subject)) + } + + it should "return a expiration date that should be within an hour of testing" in { + val exp = RefreshTokenClaimsParser.getExpiration(refreshJwt) + val check = FakeTokens.validExpiration.toInstant + assert(exp.equals(Instant.ofEpochSecond(check.getEpochSecond))) + } + + it should "return an exception for missing expiration" in { + val exception = the[LsJwtException] thrownBy { + RefreshTokenClaimsParser.getExpiration(missingAllClaimsButSubjectJwt) + } + exception.getMessage shouldBe "Expiration not found" + } + + it should "return the set time of issue" in { + val issueTime = RefreshTokenClaimsParser.getIssueTime(refreshJwt) + val check = FakeTokens.issuedAt.toInstant + assert(issueTime.equals(Instant.ofEpochSecond(check.getEpochSecond))) + } + + it should "return an exception for missing issuedAt" in { + val exception = the[LsJwtException] thrownBy { + RefreshTokenClaimsParser.getIssueTime(missingAllClaimsButSubjectJwt) + } + exception.getMessage shouldBe "Issue time not found" + } + + it should "return the token type" in { + val tokenType = RefreshTokenClaimsParser.getTokenType(refreshJwt) + assert(tokenType.equals("refresh")) + } + + it should "return an exception for missing token type" in { + val exception = the[LsJwtException] thrownBy { + RefreshTokenClaimsParser.getTokenType(missingTokenTypeJwt) + } + exception.getMessage shouldBe "Token type not found" + } + + it should "check refresh token type" in { + assert(RefreshTokenClaimsParser.isRefreshTokenType(refreshJwt)) + } + + it should "return an exception for missing token type 2" in { + val exception = the[LsJwtException] thrownBy { + RefreshTokenClaimsParser.isRefreshTokenType(missingTokenTypeJwt) + } + exception.getMessage shouldBe "Token type not found" + } +} diff --git a/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/FakeTokens.scala b/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/FakeTokens.scala new file mode 100644 index 0000000..f8cac63 --- /dev/null +++ b/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/FakeTokens.scala @@ -0,0 +1,122 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.authorization + +import io.jsonwebtoken.{Jwts, SignatureAlgorithm} +import io.jsonwebtoken.security.Keys + +import java.security.KeyPair +import java.time.Instant +import java.util +import java.util.Date + +object FakeTokens { + + val subject: String = "testUser" + val validExpiration: Date = Date.from(Instant.now().plus(java.time.Duration.ofHours(1))) + val invalidExpiration: Date = Date.from(Instant.now().minus(java.time.Duration.ofHours(1))) + val issuedAt: Date = Date.from(Instant.now()) + val groupsClaim: util.ArrayList[String] = new util.ArrayList() + groupsClaim.add("group1") + groupsClaim.add("group2") + val email: String = "testuser@org.com" + val displayName: String = "Test User" + + val keys: KeyPair = Keys.keyPairFor(SignatureAlgorithm.RS256) + val invalidKeys: KeyPair = Keys.keyPairFor(SignatureAlgorithm.RS256) + + val validAccessToken: String = Jwts.builder() + .setSubject(subject) + .setExpiration(validExpiration) + .setIssuedAt(issuedAt) + .claim("groups", groupsClaim) + .claim("email", email) + .claim("displayname", displayName) + .claim("type", "access") + .signWith(keys.getPrivate) + .compact() + + val validRefreshToken: String = Jwts.builder() + .setSubject(subject) + .setExpiration(validExpiration) + .setIssuedAt(issuedAt) + .claim("type", "refresh") + .signWith(keys.getPrivate) + .compact() + + val invalidExpirationAccessToken: String = Jwts.builder() + .setSubject(subject) + .setExpiration(invalidExpiration) + .setIssuedAt(issuedAt) + .claim("groups", groupsClaim) + .claim("email", email) + .claim("displayname", displayName) + .claim("type", "access") + .signWith(keys.getPrivate) + .compact() + + val invalidExpirationRefreshToken: String = Jwts.builder() + .setSubject(subject) + .setExpiration(invalidExpiration) + .setIssuedAt(issuedAt) + .claim("type", "refresh") + .signWith(keys.getPrivate) + .compact() + + val invalidSignatureAccessToken: String = Jwts.builder() + .setSubject(subject) + .setExpiration(validExpiration) + .setIssuedAt(issuedAt) + .claim("groups", groupsClaim) + .claim("email", email) + .claim("displayname", displayName) + .claim("type", "access") + .signWith(invalidKeys.getPrivate) + .compact() + + val invalidSignatureRefreshToken: String = Jwts.builder() + .setSubject(subject) + .setExpiration(validExpiration) + .setIssuedAt(issuedAt) + .claim("type", "refresh") + .signWith(invalidKeys.getPrivate) + .compact() + + val missingTokenTypeToken: String = Jwts.builder() + .setSubject(subject) + .setExpiration(validExpiration) + .setIssuedAt(issuedAt) + .signWith(keys.getPrivate) + .compact() + + val invalidTypeToken: String = Jwts.builder() + .setSubject(subject) + .setExpiration(validExpiration) + .setIssuedAt(issuedAt) + .claim("type", "invalid") + .signWith(keys.getPrivate) + .compact() + + val missingSubjectToken: String = Jwts.builder() + .signWith(keys.getPrivate) + .compact() + + val missingAllClaimsButSubjectToken: String = Jwts.builder() + .setSubject(subject) + .signWith(keys.getPrivate) + .compact() +} diff --git a/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/RefreshTokenVerificatorTest.scala b/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/RefreshTokenVerificatorTest.scala new file mode 100644 index 0000000..c8b8994 --- /dev/null +++ b/clientLibrary/src/test/scala/za/co/absa/loginclient/authorization/RefreshTokenVerificatorTest.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.loginclient.authorization + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import za.co.absa.loginclient.exceptions.LsJwtException +import za.co.absa.loginclient.tokenRetrieval.model.RefreshToken + +import java.util.Base64 + +class RefreshTokenVerificatorTest extends AnyFlatSpec with Matchers{ + + private val publicKeyString = Base64.getEncoder.encodeToString(FakeTokens.keys.getPublic.getEncoded) + private val decoder = JwtDecoderProvider.getDecoderFromPublicKeyString(publicKeyString) + private val refreshTokenVerificator = RefreshTokenVerificator(decoder) + + "Refresh Token" should "pass decoding" in { + val token = RefreshToken(FakeTokens.validRefreshToken) + refreshTokenVerificator.decodeAndVerifyRefreshToken(token) + } + + "Incorrectly structured Refresh Token" should "fail decoding" in { + val token = RefreshToken(FakeTokens.missingSubjectToken) + val exception = the[LsJwtException] thrownBy { + refreshTokenVerificator.decodeAndVerifyRefreshToken(token) + } + exception.getMessage shouldBe "Refresh Token Decoding Failed: An error occurred while attempting to decode the Jwt: Malformed payload" + } + + "Expired Refresh Token" should "fail decoding" in { + val token = RefreshToken(FakeTokens.invalidExpirationRefreshToken) + val exception = the[LsJwtException] thrownBy { + refreshTokenVerificator.decodeAndVerifyRefreshToken(token) + } + exception.getMessage shouldBe "Refresh Token Decoding Failed: An error occurred while attempting to decode the Jwt: expiresAt must be after issuedAt" + } + + "Incorrectly signed Refresh Token" should "fail decoding" in { + val token = RefreshToken(FakeTokens.invalidSignatureRefreshToken) + val exception = the[LsJwtException] thrownBy { + refreshTokenVerificator.decodeAndVerifyRefreshToken(token) + } + exception.getMessage shouldBe "Refresh Token Decoding Failed: An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature" + } + + "Access Token with missing Type" should "return an exception" in { + val token = RefreshToken(FakeTokens.invalidTypeToken) + val exception = the[LsJwtException] thrownBy { + refreshTokenVerificator.decodeAndVerifyRefreshToken(token) + } + exception.getMessage shouldBe "Refresh Token Verification Failed" + } +} diff --git a/examples/src/main/resources/example.application.yaml b/examples/src/main/resources/example.application.yaml new file mode 100644 index 0000000..ec3412b --- /dev/null +++ b/examples/src/main/resources/example.application.yaml @@ -0,0 +1,4 @@ +# Login-service Config +login-service: + example: + host: "http://localhost:9090" diff --git a/examples/src/main/scala/za/co/absa/clientexample/Application.scala b/examples/src/main/scala/za/co/absa/clientexample/Application.scala new file mode 100644 index 0000000..e217f39 --- /dev/null +++ b/examples/src/main/scala/za/co/absa/clientexample/Application.scala @@ -0,0 +1,119 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.clientexample + +import za.co.absa.clientexample.config.ConfigProvider +import za.co.absa.loginclient.exceptions.LsJwtException +import za.co.absa.loginclient.authorization.{AccessTokenClaimsParser, AccessTokenVerificator, JwtDecoderProvider} +import za.co.absa.loginclient.tokenRetrieval.client.TokenRetrievalClient + +import java.nio.file.{Files, Paths} +import java.util.Scanner + +object Application { + + def main(args: Array[String]): Unit = { + + var configPath = "" + if (args.length < 1) { + throw new Exception("Usage: Application ") + } else { + if (Files.exists(Paths.get(args(0)))) configPath = args(0) + else throw new Exception("Config file does not exist") + } + val config = new ConfigProvider(configPath).getExampleConfig + + val tokenRetriever = TokenRetrievalClient(config.host) + val decoder = JwtDecoderProvider.getDecoderFromURL(config.host) + val accessVerificator = AccessTokenVerificator(decoder) + val scanner = new Scanner(System.in) + + var loggedIn = false + + while (true) { + println("----------------------------------------------") + println("---------------PLEASE LOGIN-------------------") + println("----------------------------------------------") + print("Enter your username: ") + val username = scanner.nextLine() + print("Enter your password: ") + val password = scanner.nextLine() + + try { + val (accessToken, refreshToken) = tokenRetriever.fetchAccessAndRefreshToken(username, password) + val decodedAtJwt = accessVerificator.decodeAndVerifyAccessToken(accessToken) // throw Exception on verification fail + loggedIn = true + + var accessClaims = AccessTokenClaimsParser.getAllClaims(decodedAtJwt) + + println("----------------------------------------------") + println(s"${accessClaims("sub").toString.toUpperCase} HAS LOGGED IN.") + println(s"ACCESS TOKEN: $accessToken") + println(s"REFRESH TOKEN: $refreshToken") + println("----------------------------------------------") + + while (loggedIn) { + println("1) Refresh Token") + println("2) Print Claims") + println("3) Logout") + print("Enter your choice: ") + val choice = scanner.nextLine() + choice match { + case "1" => + val (newAccessToken, newRefreshToken) = tokenRetriever.refreshAccessToken(accessToken, refreshToken) + try { + val refreshedAtJwt = accessVerificator.decodeAndVerifyAccessToken(accessToken) + accessClaims = AccessTokenClaimsParser.getAllClaims(refreshedAtJwt) + println("----------------------------------------------") + println(s"NEW ACCESS TOKEN: $newAccessToken") + println(s"REFRESH TOKEN: $newRefreshToken") + println(s"${accessClaims("sub").toString.toUpperCase} HAS REFRESHED ACCESS TOKEN.") + println("----------------------------------------------") + + } catch { + case _:LsJwtException => + loggedIn = false + println("----------------------------------------------") + println(s"REFRESH TOKEN NOT VALID. PLEASE LOG IN AGAIN.") + println(s"${accessClaims("sub").toString.toUpperCase} HAS LOGGED OUT.") + println("----------------------------------------------") + } + case "2" => + println("----------------------------------------------") + println(s"CLAIMS: $accessClaims") + println("----------------------------------------------") + case "3" => + loggedIn = false + println("----------------------------------------------") + println(s"${accessClaims("sub").toString.toUpperCase} HAS LOGGED OUT.") + println("----------------------------------------------") + case _ => + println("----------------------------------------------") + println(s"INVALID CHOICE. PLEASE TRY AGAIN") + println("----------------------------------------------") + } + } + } + catch { + case e: Throwable => + println("----------------------------------------------") + println(s"UNAUTHORIZED. PLEASE TRY AGAIN") + println("----------------------------------------------") + } + } + } +} diff --git a/examples/src/main/scala/za/co/absa/clientexample/config/ConfigProvider.scala b/examples/src/main/scala/za/co/absa/clientexample/config/ConfigProvider.scala new file mode 100644 index 0000000..56fad52 --- /dev/null +++ b/examples/src/main/scala/za/co/absa/clientexample/config/ConfigProvider.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.clientexample.config + +import pureconfig._ +import pureconfig.generic.auto._ +import pureconfig.module.yaml._ + +class ConfigProvider(yamlPath: String) { + + private val yamlConfig: YamlConfigSource = YamlConfigSource.file(yamlPath) + + def getExampleConfig: ExampleConfig = { + createConfigClass[ExampleConfig]("login-service.example"). + getOrElse(throw new Exception(s"Config Not Available. Please check $yamlPath")) + } + private def createConfigClass[A](nameSpace: String)(implicit reader: ConfigReader[A]): Option[A] = { + val configProperty: ConfigSource = this.yamlConfig.at(nameSpace) + val configClass: Option[A] = configProperty.load[A].toOption + if (configProperty.value().isRight && configClass.isEmpty) + throw new Exception(s"Config properties $nameSpace found but could not be parsed, please check if correct") + + configClass + } +} diff --git a/examples/src/main/scala/za/co/absa/clientexample/config/ExampleConfig.scala b/examples/src/main/scala/za/co/absa/clientexample/config/ExampleConfig.scala new file mode 100644 index 0000000..9d4ac6f --- /dev/null +++ b/examples/src/main/scala/za/co/absa/clientexample/config/ExampleConfig.scala @@ -0,0 +1,21 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.clientexample.config + +import scala.concurrent.duration.FiniteDuration + +case class ExampleConfig(host: String) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 71b53de..875e46e 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -49,7 +49,11 @@ object Dependencies { lazy val jjwtImpl = "io.jsonwebtoken" % "jjwt-impl" % Versions.jjwt % Runtime lazy val jjwtJackson = "io.jsonwebtoken" % "jjwt-jackson" % Versions.jjwt % Runtime + lazy val jsonParser = "com.google.code.gson" % "gson" % "2.10.1" + + lazy val jwtDecoder = "org.springframework.security" % "spring-security-oauth2-jose" % Versions.spring lazy val nimbusJoseJwt = "com.nimbusds" % "nimbus-jose-jwt" % Versions.nimbusJoseJwt + lazy val bouncyCastle = "org.bouncycastle" % "bcprov-jdk15on" % "1.70" lazy val pureConfig = "com.github.pureconfig" %% "pureconfig" % Versions.pureConfig lazy val pureConfigYaml = "com.github.pureconfig" %% "pureconfig-yaml" % Versions.pureConfig @@ -62,7 +66,6 @@ object Dependencies { // this is UI + swagger annotations together, just annotathons should be in "io.swagger.core.v3":"swagger-annotations":"2.2.8"+ lazy val springDoc = "org.springdoc" % "springdoc-openapi-ui" % "1.6.14" - // Enables /actuator/health endpoint lazy val springBootStarterActuator = "org.springframework.boot" % "spring-boot-starter-actuator" % Versions.springBoot @@ -102,4 +105,27 @@ object Dependencies { springBootSecurityTest ) + def clientLibDependencies: Seq[ModuleID] = Seq( + javaCompat, + + nimbusJoseJwt, + jwtDecoder, + bouncyCastle, + + jjwtApi, + jjwtImpl, + jjwtJackson, + + jsonParser, + + springBootWeb, + springBootSecurity, + + scalaTest + ) + + def exampleDependencies: Seq[ModuleID] = Seq( + pureConfig, + pureConfigYaml + ) } diff --git a/publish.sbt b/publish.sbt index b155c68..7e87c70 100644 --- a/publish.sbt +++ b/publish.sbt @@ -35,6 +35,12 @@ ThisBuild / developers := List( name = "Bartlomiej Baj", email = "bartlomiej.baj@absa.africa", url = url("https://github.com/jakipatryk") + ), + Developer( + id = "TheLydonKing", + name = "Lydon da Rocha", + email = "lydon.darocha@absa.africa", + url = url("https://github.com/TheLydonKing") ) ) @@ -46,3 +52,14 @@ ThisBuild / description := "Login service for JWT public signing services" ThisBuild / organizationName := "ABSA Group Limited" ThisBuild / startYear := Some(2023) ThisBuild / licenses += "Apache-2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0.txt") + +ThisBuild / pomIncludeRepository := { _ => false } +ThisBuild / publishTo := { + val nexus = "https://oss.sonatype.org/" + if (isSnapshot.value) { + Some("snapshots" at s"${nexus}content/repositories/snapshots") + } else { + Some("releases" at s"${nexus}service/local/staging/deploy/maven2") + } +} +ThisBuild / publishMavenStyle := true diff --git a/service/src/main/scala/za/co/absa/loginsvc/rest/service/JWTService.scala b/service/src/main/scala/za/co/absa/loginsvc/rest/service/JWTService.scala index ce244b6..c4274be 100644 --- a/service/src/main/scala/za/co/absa/loginsvc/rest/service/JWTService.scala +++ b/service/src/main/scala/za/co/absa/loginsvc/rest/service/JWTService.scala @@ -41,7 +41,11 @@ import scala.concurrent.duration.FiniteDuration class JWTService @Autowired()(jwtConfigProvider: JwtConfigProvider) { private val logger = LoggerFactory.getLogger(classOf[JWTService]) - private val scheduler = Executors.newSingleThreadScheduledExecutor() + private val scheduler = Executors.newSingleThreadScheduledExecutor(r => { + val t = new Thread(r) + t.setDaemon(true) + t + }) private val jwtConfig = jwtConfigProvider.getJwtKeyConfig @volatile private var keyPair: KeyPair = jwtConfig.keyPair()