Skip to content

Commit

Permalink
add support for using GitHub app-based authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
gdams committed Nov 9, 2023
1 parent 604594a commit bbe9ff2
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 35 deletions.
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-----
<key contents>
-----END PRIVATE KEY-----'
```

### Build Tool

[Maven](https://maven.apache.org/index.html) is used to build the project.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ quarkus.log.category."org.mongodb".level=INFO
quarkus.log.category."net.adoptium.api.v3.dataSources.APIDataStore".level=WARN
quarkus.log.category."org.apache.http.client.protocol.ResponseProcessCookies".level=ERROR
quarkus.log.category."io.netty".level=INFO
quarkus.log.category."org.kohsuke".level=WARN
quarkus.log.category."jdk.event.security".level=WARN

quarkus.http.host=localhost

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<logger name="com.microsoft.applicationinsights" level="WARN"/>
<logger name="org.mongodb" level="INFO"/>
<logger name="io.netty" level="INFO"/>
<logger name="org.kohsuke" level="WARN"/>
<logger name="jdk.event.security" level="WARN"/>

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/tmp/updater.log</file>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ class GitHubAuthTest {
}

@Test
fun `readToken prioritizes system property if env var is not defined`() {
suspend fun `readToken prioritizes system property if env var is not defined`() {
assertFalse(System.getenv().containsKey(tokenKey))

val prevTokenProperty: String? = System.getProperty(tokenKey)
System.setProperty(tokenKey, "system-property-token")

try {
val actualToken = GitHubAuth.readToken()
val actualToken = GitHubAuth.getAuthenticationToken().token
assertEquals("system-property-token", actualToken)
} finally {
if (prevTokenProperty == null) {
Expand All @@ -69,7 +69,7 @@ class GitHubAuthTest {
}

@Test
fun `readToken falls back to property file if env var and system property are not defined`() {
suspend fun `readToken falls back to property file if env var and system property are not defined`() {
assertFalse(System.getenv().containsKey(tokenKey))
assertFalse(System.getProperties().containsKey(tokenKey))

Expand All @@ -81,19 +81,19 @@ class GitHubAuthTest {
}

try {
val actualToken = GitHubAuth.readToken()
val actualToken = GitHubAuth.getAuthenticationToken().token
assertEquals("real-file-token", actualToken)
} finally {
tokenDir.deleteRecursively()
}
}

@Test
fun readsTokenNullFromFile() {
suspend fun readsTokenNullFromFile() {
assertFalse(System.getenv().containsKey(tokenKey))
assertFalse(File(tempDir, ".adopt_api").exists())

val actualToken = GitHubAuth.readToken()
val actualToken = GitHubAuth.getAuthenticationToken().token
assertThat(actualToken, oneOf(null, ""))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@
<groupId>net.adoptium.api</groupId>
<artifactId>adoptium-http-client-datasource</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.expediagroup</groupId>
<artifactId>graphql-kotlin-ktor-client</artifactId>
Expand All @@ -30,6 +47,11 @@
<groupId>com.expediagroup</groupId>
<artifactId>graphql-kotlin-client-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.kohsuke</groupId>
<artifactId>github-api</artifactId>
<version>1.317</version>
</dependency>
<dependency>
<groupId>net.adoptium.api</groupId>
<artifactId>adoptium-api-v3-persistence</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,59 @@
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 kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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
}

companion object {
@JvmStatic
private val LOGGER = LoggerFactory.getLogger(this::class.java)
private var TOKEN: AuthInfo? = null
private val appId = System.getenv("GITHUB_APP_ID")
private val privateKey = System.getenv("GITHUB_APP_PRIVATE_KEY")
private val installationId = System.getenv("GITHUB_APP_INSTALLATION_ID")
private val mutex = Mutex()

fun readToken(): String? {
var token = System.getenv("GITHUB_TOKEN")
if (token.isNullOrEmpty()) {
token = System.getProperty("GITHUB_TOKEN")
}
suspend fun getAuthenticationToken(): AuthInfo {
return mutex.withLock {
// Detect if we are using a GitHub App
if (!appId.isNullOrEmpty() && !privateKey.isNullOrEmpty() && !installationId.isNullOrEmpty()) {
if (TOKEN == null || (TOKEN!!.expirationTime != null && TOKEN!!.expirationTime!!.before(Date()))) {
LOGGER.info("Using GitHub App for authentication")
LOGGER.info("Generating a new installation token")
val token = authenticateAsGitHubApp(appId, privateKey, installationId)
TOKEN = AuthInfo(token.token, AuthType.APP, token.expiresAt)
}
} else {
if (TOKEN == null) {
val token = readToken()
LOGGER.info("Using Personal Access Token for authentication")
TOKEN = AuthInfo(token, AuthType.TOKEN, null)
}
}
TOKEN!!
}
}

private fun readToken(): String {
var token = System.getenv("GITHUB_TOKEN")
if (token.isNullOrEmpty()) {

val userHome = System.getProperty("user.home")
Expand All @@ -33,8 +70,45 @@ class GitHubAuth {
}
if (token.isNullOrEmpty()) {
LOGGER.error("Could not find GITHUB_TOKEN")
throw FailedToAuthenticateException()
}
return token
}

private suspend 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()
.issuer(appId)
.issuedAt(Date(nowMillis))
.expiration(Date(nowMillis + 60000)) // Token valid for 1 minute
.signWith(privateKey, Jwts.SIG.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)
throw FailedToAuthenticateException()
}
}
}

class FailedToAuthenticateException : Exception("Failed to authenticate to GitHub") {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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.AuthInfo
import java.net.URL

@ApplicationScoped
Expand All @@ -16,15 +17,8 @@ open class GraphQLRequestImpl : GraphQLRequest {
private val client: GraphQLKtorClient
private val httpClient: HttpClient
val BASE_URL = "https://api.github.com/graphql"
private val TOKEN: String

init {
val token = GitHubAuth.readToken()
if (token == null) {
throw IllegalStateException("No token provided")
} else {
TOKEN = token
}
httpClient = HttpClient()
client = GraphQLKtorClient(
url = URL(BASE_URL),
Expand All @@ -34,8 +28,9 @@ open class GraphQLRequestImpl : GraphQLRequest {
}

override suspend fun <T : Any> request(query: GraphQLClientRequest<T>): GraphQLClientResponse<T> {
val authInfo: AuthInfo = GitHubAuth.getAuthenticationToken()
return client.execute(query) {
headers.append("Authorization", "Bearer $TOKEN")
headers.append("Authorization", "Bearer ${authInfo.token}")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.kohsuke</groupId>
<artifactId>github-api</artifactId>
<version>1.317</version>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import net.adoptium.api.v3.dataSources.GitHubAuth.AuthInfo

@Default
@ApplicationScoped
Expand All @@ -33,7 +34,6 @@ open class DefaultUpdaterHtmlClient @Inject constructor(
companion object {
@JvmStatic
private val LOGGER = LoggerFactory.getLogger(this::class.java)
private val TOKEN: String? = GitHubAuth.readToken()
private const val REQUEST_TIMEOUT = 12_000L
private val GITHUB_DOMAINS = listOf("api.github.com", "github.com")

Expand All @@ -50,7 +50,8 @@ open class DefaultUpdaterHtmlClient @Inject constructor(
class ResponseHandler(
val client: DefaultUpdaterHtmlClient,
private val continuation: Continuation<HttpResponse>,
val request: UrlRequest?
val request: UrlRequest?,
val token: String?
) : FutureCallback<HttpResponse> {
override fun cancelled() {
continuation.resumeWithException(Exception("cancelled"))
Expand All @@ -64,7 +65,7 @@ open class DefaultUpdaterHtmlClient @Inject constructor(
}

isARedirect(response) -> {
client.getData(UrlRequest(response.getFirstHeader("location").value, request?.lastModified), continuation)
client.getData(UrlRequest(response.getFirstHeader("location").value, request?.lastModified), continuation, token)
}

response.statusLine.statusCode == 404 -> {
Expand Down Expand Up @@ -93,14 +94,14 @@ open class DefaultUpdaterHtmlClient @Inject constructor(

override fun failed(e: java.lang.Exception?) {
if (e == null) {
continuation.resumeWithException(Exception("Failed Uknown reason"))
continuation.resumeWithException(Exception("Failed Unknown reason"))
} else {
continuation.resumeWithException(e)
}
}
}

private fun getData(urlRequest: UrlRequest, continuation: Continuation<HttpResponse>) {
private fun getData(urlRequest: UrlRequest, continuation: Continuation<HttpResponse>, token: String?) {
try {
val url = URL(urlRequest.url)
val request = RequestBuilder
Expand All @@ -112,8 +113,8 @@ 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 (token != null && GITHUB_DOMAINS.contains(url.host)) {
request.setHeader("Authorization", "token $token")
}

val client =
Expand All @@ -123,20 +124,25 @@ open class DefaultUpdaterHtmlClient @Inject constructor(
redirectingHttpClient
}

client.execute(request, ResponseHandler(this, continuation, urlRequest))
client.execute(request, ResponseHandler(this, continuation, urlRequest, token))
} catch (e: Exception) {
continuation.resumeWith(Result.failure(e))
}
}

override suspend fun getFullResponse(request: UrlRequest): HttpResponse? {
val requestURL = URL(request.url)
var authInfo: AuthInfo? = null
if (GITHUB_DOMAINS.contains(requestURL.host)) {
authInfo = GitHubAuth.getAuthenticationToken()
}
// Retry up to 10 times
for (retryCount in 1..10) {
try {
LOGGER.debug("Getting ${request.url} ${request.lastModified}")
val response: HttpResponse = withTimeout(REQUEST_TIMEOUT) {
suspendCoroutine<HttpResponse> { continuation ->
getData(request, continuation)
getData(request, continuation, authInfo?.token)
}
}
LOGGER.debug("Got ${request.url}")
Expand Down
Loading

0 comments on commit bbe9ff2

Please sign in to comment.