Skip to content

Commit bfb1240

Browse files
committed
add support for using GitHub app-based authentication
1 parent 604594a commit bfb1240

File tree

8 files changed

+274
-20
lines changed

8 files changed

+274
-20
lines changed

CONTRIBUTING.md

+18
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ If you want to use the updater tool to add entries into the database, you need t
3434

3535
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.
3636

37+
### GitHub App Authentication
38+
39+
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:
40+
41+
```bash
42+
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in your-rsa-private-key.pem -out pkcs8-key.pem
43+
```
44+
45+
Once this is done you can export the following variables at runtime:
46+
47+
```bash
48+
export GITHUB_APP_ID="1234"
49+
export GITHUB_APP_INSTALLATION_ID="1234"
50+
export GITHUB_APP_PRIVATE_KEY=$'-----BEGIN PRIVATE KEY-----
51+
<key contents>
52+
-----END PRIVATE KEY-----'
53+
```
54+
3755
### Build Tool
3856

3957
[Maven](https://maven.apache.org/index.html) is used to build the project.

adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/pom.xml

+22
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@
1616
<groupId>net.adoptium.api</groupId>
1717
<artifactId>adoptium-http-client-datasource</artifactId>
1818
</dependency>
19+
<dependency>
20+
<groupId>io.jsonwebtoken</groupId>
21+
<artifactId>jjwt-api</artifactId>
22+
<version>0.12.3</version>
23+
</dependency>
24+
<dependency>
25+
<groupId>io.jsonwebtoken</groupId>
26+
<artifactId>jjwt-impl</artifactId>
27+
<version>0.12.3</version>
28+
<scope>runtime</scope>
29+
</dependency>
30+
<dependency>
31+
<groupId>io.jsonwebtoken</groupId>
32+
<artifactId>jjwt-jackson</artifactId>
33+
<version>0.12.3</version>
34+
<scope>runtime</scope>
35+
</dependency>
1936
<dependency>
2037
<groupId>com.expediagroup</groupId>
2138
<artifactId>graphql-kotlin-ktor-client</artifactId>
@@ -30,6 +47,11 @@
3047
<groupId>com.expediagroup</groupId>
3148
<artifactId>graphql-kotlin-client-jackson</artifactId>
3249
</dependency>
50+
<dependency>
51+
<groupId>org.kohsuke</groupId>
52+
<artifactId>github-api</artifactId>
53+
<version>1.317</version>
54+
</dependency>
3355
<dependency>
3456
<groupId>net.adoptium.api</groupId>
3557
<artifactId>adoptium-api-v3-persistence</artifactId>

adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/GitHubAuth.kt

+74-5
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,57 @@
11
package net.adoptium.api.v3.dataSources.github
22

3-
import org.slf4j.LoggerFactory
43
import java.io.File
54
import java.nio.file.Files
65
import java.util.Properties
6+
import org.slf4j.LoggerFactory
7+
import io.jsonwebtoken.Jwts
8+
import io.jsonwebtoken.SignatureAlgorithm
9+
import org.kohsuke.github.GHAppInstallation
10+
import org.kohsuke.github.GHAppInstallationToken
11+
import java.security.KeyFactory
12+
import java.security.spec.PKCS8EncodedKeySpec
13+
import java.util.Base64
14+
import java.util.Date
15+
import org.kohsuke.github.GitHub
16+
import org.kohsuke.github.GitHubBuilder
717

818
class GitHubAuth {
19+
data class AuthInfo(val token: String?, val type: AuthType, val expirationTime: Date?)
20+
enum class AuthType {
21+
APP, TOKEN, NONE
22+
}
923

1024
companion object {
1125
@JvmStatic
1226
private val LOGGER = LoggerFactory.getLogger(this::class.java)
1327

14-
fun readToken(): String? {
15-
var token = System.getenv("GITHUB_TOKEN")
16-
if (token.isNullOrEmpty()) {
17-
token = System.getProperty("GITHUB_TOKEN")
28+
fun getAuthenticationToken(): AuthInfo {
29+
val appId = System.getenv("GITHUB_APP_ID")
30+
val privateKey = System.getenv("GITHUB_APP_PRIVATE_KEY")
31+
val installationId = System.getenv("GITHUB_APP_INSTALLATION_ID")
32+
33+
return if (!appId.isNullOrEmpty() && !privateKey.isNullOrEmpty() && !installationId.isNullOrEmpty()) {
34+
LOGGER.info("Using GitHub App for authentication")
35+
val token = authenticateAsGitHubApp(appId, privateKey, installationId)
36+
if (token != null) {
37+
AuthInfo(token.token, AuthType.APP, token.expiresAt)
38+
} else {
39+
LOGGER.error("Failed to authenticate as GitHub App")
40+
AuthInfo(null, AuthType.NONE, null)
41+
}
42+
} else {
43+
val token = readToken()
44+
if (token != null) {
45+
LOGGER.info("Using Personal Access Token for authentication")
46+
AuthInfo(token, AuthType.TOKEN, null)
47+
} else {
48+
AuthInfo(null, AuthType.NONE, null)
49+
}
1850
}
51+
}
1952

53+
fun readToken(): String? {
54+
var token = System.getenv("GITHUB_TOKEN")
2055
if (token.isNullOrEmpty()) {
2156

2257
val userHome = System.getProperty("user.home")
@@ -36,5 +71,39 @@ class GitHubAuth {
3671
}
3772
return token
3873
}
74+
75+
fun authenticateAsGitHubApp(appId: String, privateKey: String, installationId: String): GHAppInstallationToken? {
76+
try {
77+
// Remove the first and last lines
78+
val sanitizedKey = privateKey
79+
.replace("-----BEGIN PRIVATE KEY-----", "")
80+
.replace("-----END PRIVATE KEY-----", "")
81+
.replace("\\s".toRegex(), "")
82+
83+
// Decode the Base64 encoded key
84+
val keyBytes = Base64.getDecoder().decode(sanitizedKey)
85+
86+
// Generate the private key
87+
val keySpec = PKCS8EncodedKeySpec(keyBytes)
88+
val keyFactory = KeyFactory.getInstance("RSA")
89+
val privateKey = keyFactory.generatePrivate(keySpec)
90+
91+
// Create and sign the JWT
92+
val nowMillis = System.currentTimeMillis()
93+
val jwtToken = Jwts.builder()
94+
.setIssuer(appId)
95+
.setIssuedAt(Date(nowMillis))
96+
.setExpiration(Date(nowMillis + 60000)) // Token valid for 1 minute
97+
.signWith(privateKey, SignatureAlgorithm.RS256)
98+
.compact()
99+
100+
val gitHubApp: GitHub = GitHubBuilder().withJwtToken(jwtToken).build()
101+
val appInstallation: GHAppInstallation = gitHubApp.getApp().getInstallationById(installationId.toLong())
102+
return appInstallation.createToken().create()
103+
} catch (e: Exception) {
104+
LOGGER.error("Error authenticating as GitHub App", e)
105+
return null
106+
}
107+
}
39108
}
40109
}

adoptium-updater-parent/adoptium-datasources-parent/adoptium-github-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/github/graphql/clients/GraphQLRequestImpl.kt

+30-6
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,23 @@ import io.ktor.client.*
88
import jakarta.enterprise.context.ApplicationScoped
99
import net.adoptium.api.v3.dataSources.UpdaterJsonMapper
1010
import net.adoptium.api.v3.dataSources.github.GitHubAuth
11+
import net.adoptium.api.v3.dataSources.github.GitHubAuth.AuthType
12+
import net.adoptium.api.v3.dataSources.github.GitHubAuth.AuthInfo
1113
import java.net.URL
14+
import java.util.*
1215

1316
@ApplicationScoped
1417
open class GraphQLRequestImpl : GraphQLRequest {
1518

1619
private val client: GraphQLKtorClient
1720
private val httpClient: HttpClient
1821
val BASE_URL = "https://api.github.com/graphql"
19-
private val TOKEN: String
22+
private var AUTH_INFO: AuthInfo
2023

2124
init {
22-
val token = GitHubAuth.readToken()
23-
if (token == null) {
25+
AUTH_INFO = GitHubAuth.getAuthenticationToken()
26+
if (AUTH_INFO.token == null) {
2427
throw IllegalStateException("No token provided")
25-
} else {
26-
TOKEN = token
2728
}
2829
httpClient = HttpClient()
2930
client = GraphQLKtorClient(
@@ -34,8 +35,31 @@ open class GraphQLRequestImpl : GraphQLRequest {
3435
}
3536

3637
override suspend fun <T : Any> request(query: GraphQLClientRequest<T>): GraphQLClientResponse<T> {
38+
val expirationTime = AUTH_INFO.expirationTime
39+
// Check if token is expired
40+
if (expirationTime != null && expirationTime.before(Date())) {
41+
// Refresh the token
42+
refreshGitHubAuthToken()
43+
}
3744
return client.execute(query) {
38-
headers.append("Authorization", "Bearer $TOKEN")
45+
val authHeader = when (AUTH_INFO.type) {
46+
AuthType.APP -> "Bearer ${AUTH_INFO.token}"
47+
AuthType.TOKEN -> "token ${AUTH_INFO.token}"
48+
else -> throw IllegalStateException("Invalid authentication type")
49+
}
50+
headers.append("Authorization", authHeader)
51+
}
52+
}
53+
54+
private suspend fun refreshGitHubAuthToken() {
55+
// Obtain a new token
56+
val newAuthInfo = GitHubAuth.getAuthenticationToken()
57+
if (newAuthInfo.token != null) {
58+
// Update the AUTH_INFO with the new token and expiration time
59+
AUTH_INFO = newAuthInfo
60+
} else {
61+
// Handle the error: token could not be refreshed
62+
throw IllegalStateException("Failed to refresh GitHub authentication token")
3963
}
4064
}
4165
}

adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/pom.xml

+22
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,28 @@
4848
<groupId>org.slf4j</groupId>
4949
<artifactId>slf4j-api</artifactId>
5050
</dependency>
51+
<dependency>
52+
<groupId>io.jsonwebtoken</groupId>
53+
<artifactId>jjwt-api</artifactId>
54+
<version>0.12.3</version>
55+
</dependency>
56+
<dependency>
57+
<groupId>io.jsonwebtoken</groupId>
58+
<artifactId>jjwt-impl</artifactId>
59+
<version>0.12.3</version>
60+
<scope>runtime</scope>
61+
</dependency>
62+
<dependency>
63+
<groupId>io.jsonwebtoken</groupId>
64+
<artifactId>jjwt-jackson</artifactId>
65+
<version>0.12.3</version>
66+
<scope>runtime</scope>
67+
</dependency>
68+
<dependency>
69+
<groupId>org.kohsuke</groupId>
70+
<artifactId>github-api</artifactId>
71+
<version>1.317</version>
72+
</dependency>
5173
</dependencies>
5274

5375
<build>

adoptium-updater-parent/adoptium-datasources-parent/adoptium-http-client-datasource/src/main/kotlin/net/adoptium/api/v3/dataSources/DefaultUpdaterHtmlClient.kt

+33-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import kotlin.coroutines.Continuation
1919
import kotlin.coroutines.resume
2020
import kotlin.coroutines.resumeWithException
2121
import kotlin.coroutines.suspendCoroutine
22+
import kotlinx.coroutines.runBlocking
23+
import net.adoptium.api.v3.dataSources.GitHubAuth.AuthInfo
24+
import net.adoptium.api.v3.dataSources.GitHubAuth.AuthType
25+
import java.util.*
2226

2327
@Default
2428
@ApplicationScoped
@@ -33,7 +37,7 @@ open class DefaultUpdaterHtmlClient @Inject constructor(
3337
companion object {
3438
@JvmStatic
3539
private val LOGGER = LoggerFactory.getLogger(this::class.java)
36-
private val TOKEN: String? = GitHubAuth.readToken()
40+
private var AUTH_INFO: AuthInfo = GitHubAuth.getAuthenticationToken()
3741
private const val REQUEST_TIMEOUT = 12_000L
3842
private val GITHUB_DOMAINS = listOf("api.github.com", "github.com")
3943

@@ -112,8 +116,22 @@ open class DefaultUpdaterHtmlClient @Inject constructor(
112116
request.addHeader("If-Modified-Since", urlRequest.lastModified)
113117
}
114118

115-
if (GITHUB_DOMAINS.contains(url.host) && TOKEN != null) {
116-
request.setHeader("Authorization", "token $TOKEN")
119+
if (GITHUB_DOMAINS.contains(url.host) && AUTH_INFO.token != null) {
120+
val expirationTime = AUTH_INFO.expirationTime
121+
122+
// Check if the token has expired
123+
if (expirationTime != null && expirationTime.before(Date())) {
124+
// Refresh the token
125+
runBlocking {
126+
refreshGitHubAuthToken()
127+
}
128+
}
129+
val authHeader = when (AUTH_INFO.type) {
130+
AuthType.APP -> "Bearer ${AUTH_INFO.token}"
131+
AuthType.TOKEN -> "token ${AUTH_INFO.token}"
132+
else -> ""
133+
}
134+
request.setHeader("Authorization", authHeader)
117135
}
118136

119137
val client =
@@ -129,6 +147,18 @@ open class DefaultUpdaterHtmlClient @Inject constructor(
129147
}
130148
}
131149

150+
private suspend fun refreshGitHubAuthToken() {
151+
// Obtain a new token
152+
val newAuthInfo = GitHubAuth.getAuthenticationToken()
153+
if (newAuthInfo.token != null) {
154+
// Update the AUTH_INFO with the new token and expiration time
155+
AUTH_INFO = newAuthInfo
156+
} else {
157+
// Handle the error: token could not be refreshed
158+
throw IllegalStateException("Failed to refresh GitHub authentication token")
159+
}
160+
}
161+
132162
override suspend fun getFullResponse(request: UrlRequest): HttpResponse? {
133163
// Retry up to 10 times
134164
for (retryCount in 1..10) {

0 commit comments

Comments
 (0)