Skip to content

Commit

Permalink
Add alternative GH auth: GH app
Browse files Browse the repository at this point in the history
  • Loading branch information
ledoyen committed Nov 23, 2024
1 parent bb7dfb2 commit 52180d4
Show file tree
Hide file tree
Showing 9 changed files with 221 additions and 78 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,3 @@ buildNumber.properties
# IntelliJ
.idea/
*.iml

25 changes: 25 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
<amqp-client.version>5.22.0</amqp-client.version>
<juniversalchardet.version>2.5.0</juniversalchardet.version>
<testcontainers.version>1.20.3</testcontainers.version>
<jjwt.version>0.12.3</jjwt.version>
<bouncycastle.version>1.79</bouncycastle.version>

<junit-jupiter.version>5.11.3</junit-jupiter.version>
<assertj-core.version>3.26.3</assertj-core.version>
Expand Down Expand Up @@ -168,6 +170,29 @@
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>


<!-- Test dependencies -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class GradingJob(
val failedSlugs = mutableListOf<String>()
val jobDurations = mutableListOf<Long>()
for ((jobIndex, userSlug) in userSlugs.withIndex()) {
System.setProperty("github_user", userSlug)
val gradingConfiguration = GradingConfiguration(
repoUrlBuilder(userSlug),
"",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class GradingJobLauncher : Callable<Int> {
0
}
slug.isPresent -> {
System.setProperty("github_user", slug.get())
val repoUrl = grader.slugToRepoUrl(slug.get())
val configuration = GradingConfiguration(repoUrl, "", "")
val branch = System.getProperty("git.branch")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class ExerciseCloner(private val workspace: Path) {
if (potentialRepo.isPresent) {
if (!forcePull) {
try {
forcePull(potentialRepo.get(), uri)
forcePull(potentialRepo.get())
} catch (e: RuntimeException) {
throw RuntimeException("Could not pull -f repository: $path ($uri), ${e.message}", e)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.github.lernejo.korekto.toolkit.thirdparty.git

import com.github.lernejo.korekto.toolkit.WarningException
import com.github.lernejo.korekto.toolkit.thirdparty.github.GitHubAuthenticationHolder
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.ResetCommand
import org.eclipse.jgit.api.errors.GitAPIException
import org.eclipse.jgit.api.errors.InvalidRemoteException
import org.eclipse.jgit.api.errors.JGitInternalException
import org.eclipse.jgit.api.errors.NoHeadException
import org.eclipse.jgit.storage.file.FileRepositoryBuilder
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider
import org.slf4j.LoggerFactory
import java.io.IOException
import java.io.UncheckedIOException
Expand All @@ -20,7 +20,6 @@ object GitRepository {
private val LOGGER = LoggerFactory.getLogger(GitRepository::class.java)

private val URI_WITH_CRED_PATTERN = Pattern.compile("(?<protocol>https?://)(?<cred>[^@]+)@(?<hostAndMore>.+)")
private val URI_WITHOUT_CRED_PATTERN = Pattern.compile("(?<protocol>https?://)(?<hostAndMore>.+)")

data class Creds(val uriWithoutCred: String, val username: String?, val password: String?)

Expand Down Expand Up @@ -51,42 +50,22 @@ object GitRepository {
}
}

private fun insertToken(uri: String, token: String): String {
val uriCredMatcher = URI_WITH_CRED_PATTERN.matcher(uri)
return if (uriCredMatcher.matches()) {
uri
} else {
val uriMatcher = URI_WITHOUT_CRED_PATTERN.matcher(uri)
if (uriMatcher.matches()) {
uriMatcher.group("protocol") + "x-access-token:" + token + "@" + uriMatcher.group("hostAndMore")
} else {
uri
}
}
}

@JvmStatic
fun clone(uri: String, path: Path): Git {
val creds = extractCredParts(uri)

return try {
val cloneCommand = Git.cloneRepository()
.setURI(uri)
.setDirectory(path.toFile())
val token = System.getProperty("github_token")
if (creds.username != null) {
cloneCommand.setCredentialsProvider(UsernamePasswordCredentialsProvider(creds.username, creds.password))
} else if (token != null) {
cloneCommand
.setURI(insertToken(uri, token))
.setCredentialsProvider(UsernamePasswordCredentialsProvider(token, ""))
}
cloneCommand
.setURI(uri)
GitHubAuthenticationHolder.auth.configure(cloneCommand)

val git = cloneCommand
.setCloneAllBranches(true)
.call()
LOGGER.debug("Cloning in: " + git.repository.directory)
git
} catch(e: InvalidRemoteException) {
} catch (e: InvalidRemoteException) {
throw WarningException("Unable to clone in ${path.toAbsolutePath()}: Missing or inaccessible repository", e)
} catch (e: GitAPIException) {
throw buildWarningException(path, e)
Expand Down Expand Up @@ -122,38 +101,12 @@ object GitRepository {
}
}

internal fun extractCreds(uri: String): Creds {
val token = System.getProperty("github_token")
return if (token != null) {
Creds(uri, token, "")
} else {
extractCredParts(uri)
}
}

@JvmStatic
fun forcePull(git: Git, uri: String) {
val creds = extractCreds(uri)

try {
try {
forcePull(git, creds, "origin/master")
} catch (e: JGitInternalException) {
forcePull(git, creds, "origin/main")
}
} catch (e: GitAPIException) {
throw RuntimeException(e)
}
}

fun forcePull(git: Git, creds: Creds, ref: String) {
fun forcePull(git: Git) {
val fetchCommand = git.fetch()
.setForceUpdate(true)
if (creds.username != null) {
fetchCommand.setCredentialsProvider(UsernamePasswordCredentialsProvider(creds.username, creds.password))
}
fetchCommand
.call()
GitHubAuthenticationHolder.auth.configure(fetchCommand)
fetchCommand.call()
git.reset().setMode(ResetCommand.ResetType.HARD).call()
git.clean().setCleanDirectories(true).setForce(true).call()
git.pull().call()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package com.github.lernejo.korekto.toolkit.thirdparty.github

import io.jsonwebtoken.JwtBuilder
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.SecureDigestAlgorithm
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.eclipse.jgit.api.GitCommand
import org.eclipse.jgit.api.TransportCommand
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider
import org.kohsuke.github.GitHubBuilder
import org.testcontainers.shaded.org.bouncycastle.openssl.PEMKeyPair
import org.testcontainers.shaded.org.bouncycastle.openssl.PEMParser
import org.testcontainers.shaded.org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
import java.io.File
import java.io.FileReader
import java.net.HttpURLConnection
import java.security.PrivateKey
import java.security.PublicKey
import java.security.Security
import java.time.Instant
import java.util.*

object GitHubAuthenticationHolder {
val auth: GitHubAuthentication by lazy {
val token = System.getProperty("github_token")
val appId = System.getProperty("github_app_id")
val appPk = System.getProperty("github_app_pk")
if (token != null) {
TokenGitHubAuthentication(token)
} else if (appId != null && appPk != null) {
AppGitHubAuthentication(appId, appPk)
} else {
NoopTokenGitHubAuthentication()
}
}
}

interface GitHubAuthentication {
val type: String
fun <R, C : GitCommand<R>> configure(command: TransportCommand<in C, R>)
fun configure(conn: HttpURLConnection)
fun configure(builder: GitHubBuilder)
}

class NoopTokenGitHubAuthentication : GitHubAuthentication {
override val type = "noop"

override fun <R, C : GitCommand<R>> configure(command: TransportCommand<in C, R>) {
}

override fun configure(conn: HttpURLConnection) {
}

override fun configure(builder: GitHubBuilder) {
}
}

data class TokenGitHubAuthentication(val token: String) : GitHubAuthentication {
override val type = "token"

override fun <R, C : GitCommand<R>> configure(command: TransportCommand<in C, R>) {
command
.setCredentialsProvider(UsernamePasswordCredentialsProvider(token, ""))
}

override fun configure(conn: HttpURLConnection) {
conn.setRequestProperty("Authorization", "token $token")
}

override fun configure(builder: GitHubBuilder) {
builder.withOAuthToken(token)
}
}

class AppGitHubAuthentication(private val appId: String, appPk: String) : GitHubAuthentication {
override val type = "app-$appId (installation: " + getToken().installationId + ")"

companion object {
init {
Security.removeProvider("BC") //remove old/legacy Android-provided BC provider
Security.addProvider(BouncyCastleProvider()) // add 'real'/correct BC provider
}
}

private val privateKey = readPrivateKey(appPk)
private var jwt: Jwt? = null
private val tokensByUser: MutableMap<String, InstallationToken> = mutableMapOf()

override fun <R, C : GitCommand<R>> configure(command: TransportCommand<in C, R>) {
command.setCredentialsProvider(UsernamePasswordCredentialsProvider("x-access-token", getToken().token))
}

override fun configure(conn: HttpURLConnection) {
val token = getToken().token
conn.setRequestProperty("Authorization", "Bearer $token")
}

override fun configure(builder: GitHubBuilder) {
builder.withAppInstallationToken(getToken().token)
}

private fun getToken(): InstallationToken {
val user = System.getProperty("github_user") ?: throw IllegalStateException("Missing github_user env prop")
val token = tokensByUser[user]
if (token == null || token.isExpired()) {
tokensByUser[user] = refreshToken(user)
}
return tokensByUser[user]!!
}

private fun refreshToken(user: String): InstallationToken {
val jwt = getJwt()
val gitHubApp = GitHubBuilder().withJwtToken(jwt).build()
val installation = gitHubApp.app.getInstallationByUser(user)

val tokenResponse = installation.createToken().create()

return InstallationToken(tokenResponse.token, installation.id, tokenResponse.expiresAt.toInstant())
}

private fun getJwt(): String {
if (jwt == null || jwt!!.isExpired()) {
jwt = createJWT(appId, 590000, privateKey)
}
return jwt!!.token
}
}

class Jwt(val token: String, private val start: Long, private val duration: Long) {
fun isExpired() = (start + duration - 10_000) > System.currentTimeMillis()
}

class InstallationToken(val token: String, val installationId: Long, private val expiresAt: Instant) {
fun isExpired() = expiresAt.isAfter(Instant.now().minusSeconds(60L))
}

fun readPrivateKey(filename: String): PrivateKey {
val pemParser = PEMParser(FileReader(File(filename)))
val o: PEMKeyPair = pemParser.readObject() as PEMKeyPair
val converter = JcaPEMKeyConverter().setProvider("BC")
val kp = converter.getKeyPair(o)
return kp.private
}

fun createJWT(githubAppId: String, ttlMillis: Long, privateKey: PrivateKey): Jwt {
//The JWT signature algorithm we will be using to sign the token
val signatureAlgorithm: SecureDigestAlgorithm<PrivateKey, PublicKey> = Jwts.SIG.RS256

val nowMillis = System.currentTimeMillis()
val now = Date(nowMillis)

//Let's set the JWT Claims
val builder: JwtBuilder = Jwts.builder()
.issuedAt(now)
.issuer(githubAppId)
.signWith(privateKey, signatureAlgorithm)

//if it has been specified, let's add the expiration
if (ttlMillis > 0) {
val expMillis = nowMillis + ttlMillis
val exp = Date(expMillis)
builder.expiration(exp)
}

//Builds the JWT and serializes it to a compact, URL-safe string
return Jwt(builder.compact(), nowMillis, ttlMillis)
}
Loading

0 comments on commit 52180d4

Please sign in to comment.