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 bfddd37
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 20 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 @@ -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,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")
Expand All @@ -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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,23 @@ 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 {

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(
Expand All @@ -34,8 +35,30 @@ open class GraphQLRequestImpl : GraphQLRequest {
}

override suspend fun <T : Any> request(query: GraphQLClientRequest<T>): GraphQLClientResponse<T> {
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")
}
}
}
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,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
Expand All @@ -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")

Expand Down Expand Up @@ -112,8 +116,21 @@ 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()
}
}
val authHeader = when (AUTH_INFO.type) {
AuthType.NONE -> "Bearer token"
else -> "Bearer ${AUTH_INFO.token}"
}
request.setHeader("Authorization", authHeader)
}

val client =
Expand All @@ -129,6 +146,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) {
Expand Down
Loading

0 comments on commit bfddd37

Please sign in to comment.