Skip to content

Commit

Permalink
Merge pull request #2 from leanix/feature/CID-2732/authenticate-to-ghe
Browse files Browse the repository at this point in the history
Feature/cid 2732/authenticate to ghe
  • Loading branch information
mohamedlajmileanix authored Jul 5, 2024
2 parents 7eff05e + f0f6d64 commit 56b9cad
Show file tree
Hide file tree
Showing 19 changed files with 517 additions and 15 deletions.
32 changes: 29 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,38 @@ repositories {
mavenCentral()
}

extra["springCloudVersion"] = "2023.0.1"

dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("com.github.ben-manes.caffeine:caffeine:2.8.8")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.17.1")

// Dependencies for generating JWT token
implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.2")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("io.mockk:mockk:1.12.0")
}

configurations.all {
resolutionStrategy {
eachDependency {
when (requested.module.toString()) {
"org.bouncycastle:bcprov-jdk18on" -> useVersion("1.78")
}
}
}
}

detekt {
Expand Down Expand Up @@ -58,4 +84,4 @@ tasks.jacocoTestReport {
xml.required.set(true)
xml.outputLocation.set(File("${projectDir}/build/jacocoXml/jacocoTestReport.xml"))
}
}
}
15 changes: 15 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/GitHubAgentApplication.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package net.leanix.githubagent

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.cloud.openfeign.EnableFeignClients

@SpringBootApplication
@EnableFeignClients
@EnableConfigurationProperties(value = [net.leanix.githubagent.config.GitHubEnterpriseProperties::class])
class GitHubAgentApplication

fun main() {
runApplication<GitHubAgentApplication>()
}
11 changes: 0 additions & 11 deletions src/main/kotlin/net/leanix/githubagent/GithubAgentApplication.kt

This file was deleted.

16 changes: 16 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/client/GitHubClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package net.leanix.githubagent.client

import net.leanix.githubagent.dto.GitHubAppResponse
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestHeader

@FeignClient(name = "githubClient", url = "\${github-enterprise.baseUrl}")
interface GitHubClient {

@GetMapping("/api/v3/app")
fun getApp(
@RequestHeader("Authorization") jwt: String,
@RequestHeader("Accept") accept: String = "application/vnd.github.v3+json"
): GitHubAppResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package net.leanix.githubagent.config

import jakarta.annotation.PostConstruct
import net.leanix.githubagent.exceptions.GitHubEnterpriseConfigurationMissingException
import org.springframework.stereotype.Component

@Component
class AgentSetupValidation(
private val githubEnterpriseProperties: GitHubEnterpriseProperties
) {

@PostConstruct
fun validateConfiguration() {
val missingProperties = mutableListOf<String>()

if (githubEnterpriseProperties.baseUrl.isBlank()) {
missingProperties.add("GITHUB_ENTERPRISE_BASE_URL")
}
if (githubEnterpriseProperties.githubAppId.isBlank()) {
missingProperties.add("GITHUB_ENTERPRISE_GITHUB_APP_ID")
}
if (githubEnterpriseProperties.pemFile.isBlank()) {
missingProperties.add("GITHUB_ENTERPRISE_PEM_FILE")
}

if (missingProperties.isNotEmpty()) {
throw GitHubEnterpriseConfigurationMissingException(missingProperties.joinToString(", "))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.leanix.githubagent.config

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "github-enterprise")
data class GitHubEnterpriseProperties(
val baseUrl: String,
val githubAppId: String,
val pemFile: String,
)
11 changes: 11 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/dto/GitHubAppResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package net.leanix.githubagent.dto

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty

@JsonIgnoreProperties(ignoreUnknown = true)
data class GitHubAppResponse(
@JsonProperty("name") val name: String,
@JsonProperty("permissions") val permissions: Map<String, String>,
@JsonProperty("events") val events: List<String>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.leanix.githubagent.exceptions

class GitHubEnterpriseConfigurationMissingException(properties: String) : RuntimeException(
"Github Enterprise properties '$properties' are not set"
)
class GitHubAppInsufficientPermissionsException(message: String) : RuntimeException(message)
class FailedToCreateJWTException(message: String) : RuntimeException(message)
class UnableToConnectToGitHubEnterpriseException(message: String) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package net.leanix.githubagent.runners

import net.leanix.githubagent.services.GitHubAuthenticationService
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component

@Component
@Profile("!test")
class PostStartupRunner(private val githubAuthenticationService: GitHubAuthenticationService) : ApplicationRunner {

override fun run(args: ApplicationArguments?) {
githubAuthenticationService.generateJwtToken()
}
}
61 changes: 61 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/services/CachingService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package net.leanix.githubagent.services

import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.Expiry
import jakarta.annotation.PostConstruct
import net.leanix.githubagent.config.GitHubEnterpriseProperties
import org.springframework.stereotype.Service

@Service
class CachingService(
private val githubEnterpriseProperties: GitHubEnterpriseProperties
) {

data class CacheValue(val value: Any, val expiry: Long?)

private val cache: Cache<String, CacheValue> = Caffeine.newBuilder()
.maximumSize(100)
.expireAfter(object : Expiry<String, CacheValue> {
override fun expireAfterCreate(
key: String,
value: CacheValue,
currentTime: Long
): Long {
return value.expiry ?: Long.MAX_VALUE
}

override fun expireAfterUpdate(
key: String,
value: CacheValue,
currentTime: Long,
currentDuration: Long
): Long {
return value.expiry ?: Long.MAX_VALUE
}

override fun expireAfterRead(
key: String,
value: CacheValue,
currentTime: Long,
currentDuration: Long
): Long {
return currentDuration
}
})
.build()

fun set(key: String, value: Any, expiry: Long?) {
cache.put(key, CacheValue(value, expiry))
}

fun get(key: String): Any? {
return cache.getIfPresent(key)?.value
}

@PostConstruct
private fun init() {
set("baseUrl", githubEnterpriseProperties.baseUrl, null)
set("githubAppId", githubEnterpriseProperties.githubAppId, null)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package net.leanix.githubagent.services

import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import net.leanix.githubagent.config.GitHubEnterpriseProperties
import net.leanix.githubagent.exceptions.FailedToCreateJWTException
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.slf4j.LoggerFactory
import org.springframework.core.io.ResourceLoader
import org.springframework.stereotype.Service
import java.io.File
import java.io.IOException
import java.nio.charset.Charset
import java.nio.file.Files
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.Security
import java.security.spec.InvalidKeySpecException
import java.security.spec.PKCS8EncodedKeySpec
import java.util.*

@Service
class GitHubAuthenticationService(
private val cachingService: CachingService,
private val githubEnterpriseProperties: GitHubEnterpriseProperties,
private val resourceLoader: ResourceLoader,
private val gitHubEnterpriseService: GitHubEnterpriseService
) {

companion object {
private const val JWT_EXPIRATION_DURATION = 600000L
private const val pemPrefix = "-----BEGIN RSA PRIVATE KEY-----"
private const val pemSuffix = "-----END RSA PRIVATE KEY-----"
private val logger = LoggerFactory.getLogger(GitHubAuthenticationService::class.java)
}

fun generateJwtToken() {
runCatching {
logger.info("Generating JWT token")
Security.addProvider(BouncyCastleProvider())
val rsaPrivateKey: String = readPrivateKey()
val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(rsaPrivateKey))
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec)
val jwt = createJwtToken(privateKey)
cachingService.set("jwtToken", jwt.getOrThrow(), JWT_EXPIRATION_DURATION)
gitHubEnterpriseService.verifyJwt(jwt.getOrThrow())
}.onFailure {
logger.error("Failed to generate/validate JWT token", it)
if (it is InvalidKeySpecException) {
throw IllegalArgumentException("The provided private key is not in a valid PKCS8 format.", it)
} else {
throw it
}
}
}

private fun createJwtToken(privateKey: PrivateKey): Result<String> {
return runCatching {
Jwts.builder()
.setIssuedAt(Date())
.setExpiration(Date(System.currentTimeMillis() + JWT_EXPIRATION_DURATION))
.setIssuer(cachingService.get("githubAppId").toString())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact()
}.onFailure {
throw FailedToCreateJWTException("Failed to generate a valid JWT token")
}
}

@Throws(IOException::class)
private fun readPrivateKey(): String {
val pemFile = File(resourceLoader.getResource("file:${githubEnterpriseProperties.pemFile}").uri)
val fileContent = String(Files.readAllBytes(pemFile.toPath()), Charset.defaultCharset()).trim()

require(fileContent.startsWith(pemPrefix) && fileContent.endsWith(pemSuffix)) {
"The provided file is not a valid PEM file."
}

return fileContent
.replace(pemPrefix, "")
.replace(System.lineSeparator().toRegex(), "")
.replace(pemSuffix, "")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package net.leanix.githubagent.services

import net.leanix.githubagent.client.GitHubClient
import net.leanix.githubagent.dto.GitHubAppResponse
import net.leanix.githubagent.exceptions.GitHubAppInsufficientPermissionsException
import net.leanix.githubagent.exceptions.UnableToConnectToGitHubEnterpriseException
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

@Service
class GitHubEnterpriseService(private val githubClient: GitHubClient) {

companion object {
val expectedPermissions = listOf("administration", "contents", "metadata")
val expectedEvents = listOf("label", "public", "repository")
}
private val logger = LoggerFactory.getLogger(GitHubEnterpriseService::class.java)

fun verifyJwt(jwt: String) {
runCatching {
val githubApp = githubClient.getApp("Bearer $jwt")
validateGithubAppResponse(githubApp)
logger.info("Authenticated as GitHub App: '${githubApp.name}'")
}.onFailure {
logger.error("Failed to verify JWT token", it)
when (it) {
is GitHubAppInsufficientPermissionsException -> throw it
else -> throw UnableToConnectToGitHubEnterpriseException("Failed to verify JWT token")
}
}
}

fun validateGithubAppResponse(response: GitHubAppResponse) {
val missingPermissions = expectedPermissions.filterNot { response.permissions.containsKey(it) }
val missingEvents = expectedEvents.filterNot { response.events.contains(it) }

if (missingPermissions.isNotEmpty() || missingEvents.isNotEmpty()) {
var message = "GitHub App is missing the following "
if (missingPermissions.isNotEmpty()) {
message = message.plus("permissions: $missingPermissions")
}
if (missingEvents.isNotEmpty()) {
if (missingPermissions.isNotEmpty()) {
message = message.plus(", and the following")
}
message = message.plus("events: $missingEvents")
}
throw GitHubAppInsufficientPermissionsException(message)
}
}
}
4 changes: 4 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
github-enterprise:
baseUrl: ${GITHUB_ENTERPRISE_BASE_URL:}
githubAppId: ${GITHUB_APP_ID:}
pemFile: ${PEM_FILE:}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package net.leanix.githubagent

import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles

@SpringBootTest
class GithubAgentApplicationTests {
@ActiveProfiles("test")
class GitHubAgentApplicationTests {

@Test
fun contextLoads() {
Expand Down
Loading

0 comments on commit 56b9cad

Please sign in to comment.