Skip to content

Commit

Permalink
Feature/11 client library for convenience (#81)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
TheLydonKing and dk1844 authored Jan 10, 2024
1 parent 58b7f19 commit b9c9230
Show file tree
Hide file tree
Showing 23 changed files with 1,448 additions and 11 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
31 changes: 23 additions & 8 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
)

Expand All @@ -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)
68 changes: 68 additions & 0 deletions clientLibrary/README.md
Original file line number Diff line number Diff line change
@@ -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

`<dependency>
<groupId>za.co.absa</groupId>
<artifactId>login-service-client-library_2.12</artifactId>
<version>$VERSION</version>
</dependency>`

### 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.
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading

0 comments on commit b9c9230

Please sign in to comment.