From ee2bd2057accdca4e8e0f0e165c4d3d072cc1864 Mon Sep 17 00:00:00 2001 From: George Adams Date: Wed, 8 Nov 2023 21:05:09 +0000 Subject: [PATCH] add support for using GitHub app-based authentication --- CONTRIBUTING.md | 18 +++++ .../adoptium-github-datasource/pom.xml | 22 ++++++ .../api/v3/dataSources/github/GitHubAuth.kt | 79 +++++++++++++++++-- .../graphql/clients/GraphQLRequestImpl.kt | 35 ++++++-- .../adoptium-http-client-datasource/pom.xml | 22 ++++++ .../dataSources/DefaultUpdaterHtmlClient.kt | 31 +++++++- .../adoptium/api/v3/dataSources/GitHubAuth.kt | 77 ++++++++++++++++-- docker-compose.yml | 4 +- 8 files changed, 268 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5a9665166..4f66ec001a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,24 @@ If you want to use the updater tool to add entries into the database, you need t The production server uses mongodb to store data, however you can also use Fongo. If you would like to install mongodb and are on mac, I used this [guide](https://zellwk.com/blog/install-mongodb/) which utilizes homebrew. You can also install `mongo` which is a command-line tool that gives you access to your mongodb, allowing you to manually search through the database. +### GitHub App Authentication + +The updater can be used with a GitHub Token or GitHub App. To use a GitHub app you need to generate an app on GitHub. Once you've done that you need to convert the key to PKCS#8 format using the following command: + +```bash +openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in your-rsa-private-key.pem -out pkcs8-key.pem +``` + +Once this is done you can export the following variables at runtime: + +```bash +export GITHUB_APP_ID="1234" +export GITHUB_APP_INSTALLATION_ID="1234" +export GITHUB_APP_PRIVATE_KEY=$'-----BEGIN PRIVATE KEY----- + +-----END PRIVATE KEY-----' +``` + ### Build Tool [Maven](https://maven.apache.org/index.html) is used to build the project. diff --git a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/pom.xml b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/pom.xml index e61ce282c2..c468bd4905 100644 --- a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/pom.xml +++ b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/pom.xml @@ -16,6 +16,23 @@ net.adoptium.api adoptium-http-client-datasource + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + com.expediagroup graphql-kotlin-ktor-client @@ -30,6 +47,11 @@ com.expediagroup graphql-kotlin-client-jackson + + org.kohsuke + github-api + 1.317 + net.adoptium.api adoptium-api-v3-persistence diff --git a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/GitHubAuth.kt b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/GitHubAuth.kt index 85f1ab5e09..325ad52569 100644 --- a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/GitHubAuth.kt +++ b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/GitHubAuth.kt @@ -1,22 +1,57 @@ package net.adoptium.api.v3.dataSources.github -import org.slf4j.LoggerFactory import java.io.File import java.nio.file.Files import java.util.Properties +import org.slf4j.LoggerFactory +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import org.kohsuke.github.GHAppInstallation +import org.kohsuke.github.GHAppInstallationToken +import java.security.KeyFactory +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import java.util.Date +import org.kohsuke.github.GitHub +import org.kohsuke.github.GitHubBuilder class GitHubAuth { + data class AuthInfo(val token: String?, val type: AuthType, val expirationTime: Date?) + enum class AuthType { + APP, TOKEN, NONE + } companion object { @JvmStatic private val LOGGER = LoggerFactory.getLogger(this::class.java) - fun readToken(): String? { - var token = System.getenv("GITHUB_TOKEN") - if (token.isNullOrEmpty()) { - token = System.getProperty("GITHUB_TOKEN") + fun getAuthenticationToken(): AuthInfo { + val appId = System.getenv("GITHUB_APP_ID") + val privateKey = System.getenv("GITHUB_APP_PRIVATE_KEY") + val installationId = System.getenv("GITHUB_APP_INSTALLATION_ID") + + return if (!appId.isNullOrEmpty() && !privateKey.isNullOrEmpty() && !installationId.isNullOrEmpty()) { + LOGGER.info("Using GitHub App for authentication") + val token = authenticateAsGitHubApp(appId, privateKey, installationId) + if (token != null) { + AuthInfo(token.token, AuthType.APP, token.expiresAt) + } else { + LOGGER.error("Failed to authenticate as GitHub App") + AuthInfo(null, AuthType.NONE, null) + } + } else { + val token = readToken() + if (token != null) { + LOGGER.info("Using Personal Access Token for authentication") + AuthInfo(token, AuthType.TOKEN, null) + } else { + AuthInfo(null, AuthType.NONE, null) + } } + } + fun readToken(): String? { + var token = System.getenv("GITHUB_TOKEN") if (token.isNullOrEmpty()) { val userHome = System.getProperty("user.home") @@ -36,5 +71,39 @@ class GitHubAuth { } return token } + + fun authenticateAsGitHubApp(appId: String, privateKey: String, installationId: String): GHAppInstallationToken? { + try { + // Remove the first and last lines + val sanitizedKey = privateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + + // Decode the Base64 encoded key + val keyBytes = Base64.getDecoder().decode(sanitizedKey) + + // Generate the private key + val keySpec = PKCS8EncodedKeySpec(keyBytes) + val keyFactory = KeyFactory.getInstance("RSA") + val privateKey = keyFactory.generatePrivate(keySpec) + + // Create and sign the JWT + val nowMillis = System.currentTimeMillis() + val jwtToken = Jwts.builder() + .setIssuer(appId) + .setIssuedAt(Date(nowMillis)) + .setExpiration(Date(nowMillis + 60000)) // Token valid for 1 minute + .signWith(privateKey, SignatureAlgorithm.RS256) + .compact() + + val gitHubApp: GitHub = GitHubBuilder().withJwtToken(jwtToken).build() + val appInstallation: GHAppInstallation = gitHubApp.getApp().getInstallationById(installationId.toLong()) + return appInstallation.createToken().create() + } catch (e: Exception) { + LOGGER.error("Error authenticating as GitHub App", e) + return null + } + } } } diff --git a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/graphql/clients/GraphQLRequestImpl.kt b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/graphql/clients/GraphQLRequestImpl.kt index 8b34e75785..82ca7ea28b 100644 --- a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/graphql/clients/GraphQLRequestImpl.kt +++ b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/graphql/clients/GraphQLRequestImpl.kt @@ -8,7 +8,10 @@ import io.ktor.client.* import jakarta.enterprise.context.ApplicationScoped import net.adoptium.api.v3.dataSources.UpdaterJsonMapper import net.adoptium.api.v3.dataSources.github.GitHubAuth +import net.adoptium.api.v3.dataSources.github.GitHubAuth.AuthType +import net.adoptium.api.v3.dataSources.github.GitHubAuth.AuthInfo import java.net.URL +import java.util.* @ApplicationScoped open class GraphQLRequestImpl : GraphQLRequest { @@ -16,14 +19,12 @@ open class GraphQLRequestImpl : GraphQLRequest { private val client: GraphQLKtorClient private val httpClient: HttpClient val BASE_URL = "https://api.github.com/graphql" - private val TOKEN: String + private var AUTH_INFO: AuthInfo init { - val token = GitHubAuth.readToken() - if (token == null) { + AUTH_INFO = GitHubAuth.getAuthenticationToken() + if (AUTH_INFO.token == null) { throw IllegalStateException("No token provided") - } else { - TOKEN = token } httpClient = HttpClient() client = GraphQLKtorClient( @@ -34,8 +35,30 @@ open class GraphQLRequestImpl : GraphQLRequest { } override suspend fun request(query: GraphQLClientRequest): GraphQLClientResponse { + val expirationTime = AUTH_INFO.expirationTime + // Check if token is expired + if (expirationTime != null && expirationTime.before(Date())) { + // Refresh the token + refreshGitHubAuthToken() + } return client.execute(query) { - headers.append("Authorization", "Bearer $TOKEN") + val authHeader = when (AUTH_INFO.type) { + AuthType.NONE -> "Bearer token" + else -> "Bearer ${AUTH_INFO.token}" + } + headers.append("Authorization", authHeader) + } + } + + private suspend fun refreshGitHubAuthToken() { + // Obtain a new token + val newAuthInfo = GitHubAuth.getAuthenticationToken() + if (newAuthInfo.token != null) { + // Update the AUTH_INFO with the new token and expiration time + AUTH_INFO = newAuthInfo + } else { + // Handle the error: token could not be refreshed + throw IllegalStateException("Failed to refresh GitHub authentication token") } } } diff --git a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/pom.xml b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/pom.xml index f6daf74786..7d66572846 100644 --- a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/pom.xml +++ b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/pom.xml @@ -48,6 +48,28 @@ org.slf4j slf4j-api + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + org.kohsuke + github-api + 1.317 + diff --git a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/DefaultUpdaterHtmlClient.kt b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/DefaultUpdaterHtmlClient.kt index 1187d4a1ea..62e90ec536 100644 --- a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/DefaultUpdaterHtmlClient.kt +++ b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/DefaultUpdaterHtmlClient.kt @@ -19,6 +19,10 @@ import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.runBlocking +import net.adoptium.api.v3.dataSources.GitHubAuth.AuthInfo +import net.adoptium.api.v3.dataSources.GitHubAuth.AuthType +import java.util.* @Default @ApplicationScoped @@ -33,7 +37,7 @@ open class DefaultUpdaterHtmlClient @Inject constructor( companion object { @JvmStatic private val LOGGER = LoggerFactory.getLogger(this::class.java) - private val TOKEN: String? = GitHubAuth.readToken() + private var AUTH_INFO: AuthInfo = GitHubAuth.getAuthenticationToken() private const val REQUEST_TIMEOUT = 12_000L private val GITHUB_DOMAINS = listOf("api.github.com", "github.com") @@ -112,8 +116,17 @@ open class DefaultUpdaterHtmlClient @Inject constructor( request.addHeader("If-Modified-Since", urlRequest.lastModified) } - if (GITHUB_DOMAINS.contains(url.host) && TOKEN != null) { - request.setHeader("Authorization", "token $TOKEN") + if (GITHUB_DOMAINS.contains(url.host) && AUTH_INFO.token != null) { + val expirationTime = AUTH_INFO.expirationTime + + // Check if the token has expired + if (expirationTime != null && expirationTime.before(Date())) { + // Refresh the token + runBlocking { + refreshGitHubAuthToken() + } + } + request.setHeader("Authorization", "token ${AUTH_INFO.token}") } val client = @@ -129,6 +142,18 @@ open class DefaultUpdaterHtmlClient @Inject constructor( } } + private suspend fun refreshGitHubAuthToken() { + // Obtain a new token + val newAuthInfo = GitHubAuth.getAuthenticationToken() + if (newAuthInfo.token != null) { + // Update the AUTH_INFO with the new token and expiration time + AUTH_INFO = newAuthInfo + } else { + // Handle the error: token could not be refreshed + throw IllegalStateException("Failed to refresh GitHub authentication token") + } + } + override suspend fun getFullResponse(request: UrlRequest): HttpResponse? { // Retry up to 10 times for (retryCount in 1..10) { diff --git a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/GitHubAuth.kt b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/GitHubAuth.kt index 76892930f7..ade115dfdf 100644 --- a/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/GitHubAuth.kt +++ b/adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/GitHubAuth.kt @@ -1,22 +1,55 @@ package net.adoptium.api.v3.dataSources +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import org.kohsuke.github.GHAppInstallation +import org.kohsuke.github.GHAppInstallationToken +import org.kohsuke.github.GitHub +import org.kohsuke.github.GitHubBuilder import org.slf4j.LoggerFactory import java.io.File import java.nio.file.Files -import java.util.Properties +import java.security.KeyFactory +import java.security.spec.PKCS8EncodedKeySpec +import java.util.* class GitHubAuth { + data class AuthInfo(val token: String?, val type: AuthType, val expirationTime: Date?) + enum class AuthType { + APP, TOKEN, NONE + } companion object { @JvmStatic private val LOGGER = LoggerFactory.getLogger(this::class.java) - fun readToken(): String? { - var token = System.getenv("GITHUB_TOKEN") - if (token.isNullOrEmpty()) { - token = System.getProperty("GITHUB_TOKEN") + fun getAuthenticationToken(): AuthInfo { + val appId = System.getenv("GITHUB_APP_ID") + val privateKey = System.getenv("GITHUB_APP_PRIVATE_KEY") + val installationId = System.getenv("GITHUB_APP_INSTALLATION_ID") + + return if (!appId.isNullOrEmpty() && !privateKey.isNullOrEmpty() && !installationId.isNullOrEmpty()) { + LOGGER.info("Using GitHub App for authentication") + val token = authenticateAsGitHubApp(appId, privateKey, installationId) + if (token != null) { + AuthInfo(token.token, AuthType.APP, token.expiresAt) + } else { + LOGGER.error("Failed to authenticate as GitHub App") + AuthInfo(null, AuthType.NONE, null) + } + } else { + val token = readToken() + if (token != null) { + LOGGER.info("Using Personal Access Token for authentication") + AuthInfo(token, AuthType.TOKEN, null) + } else { + AuthInfo(null, AuthType.NONE, null) + } } + } + fun readToken(): String? { + var token = System.getenv("GITHUB_TOKEN") if (token.isNullOrEmpty()) { val userHome = System.getProperty("user.home") @@ -36,5 +69,39 @@ class GitHubAuth { } return token } + + fun authenticateAsGitHubApp(appId: String, privateKey: String, installationId: String): GHAppInstallationToken? { + try { + // Remove the first and last lines + val sanitizedKey = privateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + + // Decode the Base64 encoded key + val keyBytes = Base64.getDecoder().decode(sanitizedKey) + + // Generate the private key + val keySpec = PKCS8EncodedKeySpec(keyBytes) + val keyFactory = KeyFactory.getInstance("RSA") + val privateKey = keyFactory.generatePrivate(keySpec) + + // Create and sign the JWT + val nowMillis = System.currentTimeMillis() + val jwtToken = Jwts.builder() + .setIssuer(appId) + .setIssuedAt(Date(nowMillis)) + .setExpiration(Date(nowMillis + 60000)) // Token valid for 1 minute + .signWith(privateKey, SignatureAlgorithm.RS256) + .compact() + + val gitHubApp: GitHub = GitHubBuilder().withJwtToken(jwtToken).build() + val appInstallation: GHAppInstallation = gitHubApp.getApp().getInstallationById(installationId.toLong()) + return appInstallation.createToken().create() + } catch (e: Exception) { + LOGGER.error("Error authenticating as GitHub App", e) + return null + } + } } } diff --git a/docker-compose.yml b/docker-compose.yml index 1d7bce14b3..a98a67e0c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,4 +26,6 @@ services: environment: MONGODB_HOST: mongodb GITHUB_TOKEN: "${GITHUB_TOKEN}" - + GITHUB_APP_ID: "${GITHUB_APP_ID}" + GITHUB_APP_PRIVATE_KEY: "${GITHUB_APP_PRIVATE_KEY}" + GITHUB_APP_INSTALLATION_ID: "${GITHUB_APP_INSTALLATION_ID}"