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 all 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
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")
mohamedlajmileanix marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
}

detekt {
Expand Down Expand Up @@ -58,4 +84,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
@@ -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>()
}

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()
}
}
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
mohamedlajmileanix marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading