Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/cid 2732/authenticate to ghe #2

Merged
merged 6 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,36 @@ 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.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")
mohamedlajmileanix marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
}

detekt {
Expand Down Expand Up @@ -58,4 +82,4 @@ tasks.jacocoTestReport {
xml.required.set(true)
xml.outputLocation.set(File("${projectDir}/build/jacocoXml/jacocoTestReport.xml"))
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
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(value = ["net.leanix.githubagent.client"])
mohamedlajmileanix marked this conversation as resolved.
Show resolved Hide resolved
@EnableConfigurationProperties(value = [net.leanix.githubagent.config.GithubEnterpriseProperties::class])
class GithubAgentApplication

fun main() {
Expand Down
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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package net.leanix.githubagent.services

import jakarta.annotation.PostConstruct
import net.leanix.githubagent.config.GithubEnterpriseProperties
import org.springframework.stereotype.Service

@Service
class CachingService(
private val githubEnterpriseProperties: GithubEnterpriseProperties
) {
private val cache = HashMap<String, String?>()
mohamedlajmileanix marked this conversation as resolved.
Show resolved Hide resolved

@PostConstruct
private fun init() {
cache["baseUrl"] = githubEnterpriseProperties.baseUrl
cache["githubAppId"] = githubEnterpriseProperties.githubAppId
}

fun set(key: String, value: String) {
cache[key] = value
}

fun get(key: String): String? {
return cache[key]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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 {
when (it) {
is GithubAppInsufficientPermissionsException -> throw it
else -> throw UnableToConnectToGithubEnterpriseException("Failed to verify JWT token")
mohamedlajmileanix marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

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)
}
}
}
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())
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"))
.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, "")
}
}
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,8 +2,10 @@ package net.leanix.githubagent

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

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

@Test
Expand Down
Loading
Loading