-
Notifications
You must be signed in to change notification settings - Fork 1
feat(auth): self-refreshing GitHub user tokens for deployment approvals #1199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
0e2d258
f902971
d6e091c
4e2bc2f
32f1d76
be9976a
fe60c56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| package de.tum.cit.aet.helios.auth.github.token; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import de.tum.cit.aet.helios.github.GitHubConfig; | ||
| import java.io.IOException; | ||
| import java.time.OffsetDateTime; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.log4j.Log4j2; | ||
| import okhttp3.FormBody; | ||
| import okhttp3.OkHttpClient; | ||
| import okhttp3.Request; | ||
| import okhttp3.Response; | ||
| import okhttp3.ResponseBody; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| /** | ||
| * Refreshes a user's GitHub token directly against GitHub's OAuth token endpoint using the login | ||
| * App's {@code client_id}/{@code client_secret} and a stored refresh token. GitHub rotates refresh | ||
| * tokens on use, so the returned {@link GitHubUserTokenRecord} carries a <em>new</em> refresh token | ||
| * the caller must persist. | ||
| * | ||
| * <p>GitHub returns HTTP 200 even for OAuth errors (e.g. {@code bad_refresh_token}), with an | ||
| * {@code error} field in the body — those mean the user must re-authorize and surface as | ||
| * {@link GitHubReauthRequiredException}. Genuine transport/5xx failures surface as plain | ||
| * {@link IOException} so the caller can treat them as transient. | ||
| */ | ||
| @Log4j2 | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class GitHubOAuthTokenClient { | ||
|
|
||
| private static final String TOKEN_URL = "https://github.com/login/oauth/access_token"; | ||
|
|
||
| private final OkHttpClient okHttpClient; | ||
| private final ObjectMapper objectMapper; | ||
| private final GitHubConfig gitHubConfig; | ||
|
|
||
| /** | ||
| * Exchanges {@code refreshToken} for a fresh access token (and a rotated refresh token). | ||
| * | ||
| * @throws GitHubReauthRequiredException if GitHub rejects the refresh token (re-login needed) | ||
| * @throws IOException on transport failure or a non-2xx response (transient) | ||
| */ | ||
| public GitHubUserTokenRecord refresh(String refreshToken) throws IOException { | ||
| String clientSecret = gitHubConfig.getClientSecret(); | ||
| if (clientSecret == null || clientSecret.isBlank()) { | ||
| throw new IOException( | ||
| "GitHub OAuth client secret is not configured (GITHUB_CLIENT_SECRET); cannot refresh " | ||
| + "user tokens."); | ||
| } | ||
|
|
||
| FormBody body = | ||
| new FormBody.Builder() | ||
| .add("client_id", gitHubConfig.getClientId()) | ||
| .add("client_secret", clientSecret) | ||
| .add("grant_type", "refresh_token") | ||
| .add("refresh_token", refreshToken) | ||
| .build(); | ||
|
|
||
| Request request = | ||
| new Request.Builder() | ||
| .url(TOKEN_URL) | ||
| .post(body) | ||
| .header("Accept", "application/json") | ||
| .build(); | ||
|
|
||
| try (Response response = okHttpClient.newCall(request).execute()) { | ||
| ResponseBody responseBody = response.body(); | ||
| String content = responseBody == null ? "" : responseBody.string(); | ||
| if (!response.isSuccessful()) { | ||
| throw new IOException("GitHub token refresh failed: HTTP " + response.code()); | ||
| } | ||
|
|
||
| JsonNode json = objectMapper.readTree(content); | ||
| if (json.hasNonNull("error")) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @krusche [medium] Every OAuth error is classified as user reauthorization, including app-side failures such as 🤖 Prompt for AI agentsIn |
||
| throw new GitHubReauthRequiredException( | ||
| "GitHub refused to refresh the token: " + json.get("error").asText()); | ||
| } | ||
| if (!json.hasNonNull("access_token") || json.get("access_token").asText().isBlank()) { | ||
| throw new GitHubReauthRequiredException( | ||
| "GitHub refresh response contained no access token."); | ||
| } | ||
|
|
||
| OffsetDateTime now = OffsetDateTime.now(); | ||
| String accessToken = json.get("access_token").asText(); | ||
| OffsetDateTime accessExpiry = now.plusSeconds(json.path("expires_in").asLong(0)); | ||
| String newRefreshToken = | ||
| json.hasNonNull("refresh_token") ? json.get("refresh_token").asText() : null; | ||
| OffsetDateTime refreshExpiry = | ||
| json.hasNonNull("refresh_token_expires_in") | ||
| ? now.plusSeconds(json.get("refresh_token_expires_in").asLong()) | ||
| : null; | ||
| return new GitHubUserTokenRecord(accessToken, accessExpiry, newRefreshToken, refreshExpiry); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package de.tum.cit.aet.helios.auth.github.token; | ||
|
|
||
| import java.io.IOException; | ||
|
|
||
| /** | ||
| * Signals that a user's GitHub authorization can no longer be refreshed — the refresh token was | ||
| * revoked, expired, or rotated away — and the user must sign in again to re-establish it. | ||
| * | ||
| * <p>Extends {@link IOException} so it flows through the existing approval-path signatures; the | ||
| * approval service maps it to an actionable "sign out and back in" message. | ||
| */ | ||
| public class GitHubReauthRequiredException extends IOException { | ||
|
|
||
| public GitHubReauthRequiredException(String message) { | ||
| super(message); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| package de.tum.cit.aet.helios.auth.github.token; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.GenerationType; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.PrePersist; | ||
| import jakarta.persistence.PreUpdate; | ||
| import jakarta.persistence.Table; | ||
| import java.time.OffsetDateTime; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
| import lombok.Setter; | ||
| import lombok.ToString; | ||
|
|
||
| /** | ||
| * Persisted GitHub token material for one GitHub user, so Helios can hold a valid user access | ||
| * token for deployment approvals without an interactive session. The token columns hold AES-GCM | ||
| * ciphertext (see {@link TokenCipher}); this entity never carries plaintext, and its | ||
| * {@code toString()} excludes them. | ||
| */ | ||
| @Entity | ||
| @Table(name = "github_user_token") | ||
| @Getter | ||
| @Setter | ||
| @NoArgsConstructor | ||
| @ToString(exclude = {"accessTokenEnc", "refreshTokenEnc"}) | ||
| public class GitHubUserToken { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(name = "github_login", nullable = false, unique = true) | ||
| private String githubLogin; | ||
|
|
||
| @Column(name = "access_token_enc") | ||
| private String accessTokenEnc; | ||
|
|
||
| @Column(name = "refresh_token_enc") | ||
| private String refreshTokenEnc; | ||
|
|
||
| @Column(name = "access_token_expires_at") | ||
| private OffsetDateTime accessTokenExpiresAt; | ||
|
|
||
| @Column(name = "refresh_token_expires_at") | ||
| private OffsetDateTime refreshTokenExpiresAt; | ||
|
|
||
| @Column(name = "updated_at", nullable = false) | ||
| private OffsetDateTime updatedAt; | ||
|
|
||
| @PrePersist | ||
| @PreUpdate | ||
| void stampUpdatedAt() { | ||
| this.updatedAt = OffsetDateTime.now(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package de.tum.cit.aet.helios.auth.github.token; | ||
|
|
||
| import java.time.OffsetDateTime; | ||
|
|
||
| /** | ||
| * Immutable snapshot of a user's GitHub tokens with absolute expiry instants. Carried from the | ||
| * seed (Keycloak retrieve-token) and refresh (GitHub) clients into {@link GitHubUserTokenService}. | ||
| * | ||
| * <p>{@code refreshToken} / {@code refreshTokenExpiresAt} may be {@code null} when GitHub did not | ||
| * issue a refresh token (e.g. the App has token expiration disabled). | ||
| */ | ||
| public record GitHubUserTokenRecord( | ||
| String accessToken, | ||
| OffsetDateTime accessTokenExpiresAt, | ||
| String refreshToken, | ||
| OffsetDateTime refreshTokenExpiresAt) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package de.tum.cit.aet.helios.auth.github.token; | ||
|
|
||
| import java.util.Optional; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| @Repository | ||
| public interface GitHubUserTokenRepository extends JpaRepository<GitHubUserToken, Long> { | ||
|
|
||
| Optional<GitHubUserToken> findByGithubLogin(String githubLogin); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package de.tum.cit.aet.helios.auth.github.token; | ||
|
|
||
| import java.io.IOException; | ||
| import java.time.Duration; | ||
| import java.time.OffsetDateTime; | ||
| import java.util.Optional; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.log4j.Log4j2; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| /** | ||
| * Hands out a currently-valid GitHub <em>user</em> access token for {@code githubLogin}, refreshing | ||
| * as needed, so deployment approvals no longer depend on how recently the user logged in. | ||
| * | ||
| * <p>Resolution order: | ||
| * | ||
| * <ol> | ||
| * <li>a cached access token that is still comfortably valid → return it (no network); | ||
| * <li>otherwise a stored, unexpired refresh token → refresh against GitHub, persist the rotated | ||
| * tokens, return the new access token; | ||
| * <li>otherwise seed the refresh token from Keycloak (retrieve-token), then refresh. | ||
| * </ol> | ||
| * | ||
| * <p>GitHub rotates refresh tokens on use, so every refresh persists the <em>new</em> refresh | ||
| * token. When no refresh path can succeed, {@link GitHubReauthRequiredException} propagates so the | ||
| * caller can tell the user to sign in again. | ||
| */ | ||
| @Log4j2 | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class GitHubUserTokenService { | ||
|
|
||
| /** Refresh a little before actual expiry so a token handed out is still valid on use. */ | ||
| private static final Duration EXPIRY_MARGIN = Duration.ofSeconds(60); | ||
|
|
||
| private final GitHubUserTokenRepository repository; | ||
| private final TokenCipher tokenCipher; | ||
| private final KeycloakBrokerTokenClient brokerClient; | ||
| private final GitHubOAuthTokenClient oauthClient; | ||
|
|
||
| @Transactional | ||
| public String getValidAccessToken(String githubLogin) throws IOException { | ||
| OffsetDateTime now = OffsetDateTime.now(); | ||
| Optional<GitHubUserToken> existing = repository.findByGithubLogin(githubLogin); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @krusche [high] This read does not lock or otherwise serialize refreshes for a login. GitHub documents refresh tokens as single-use, so two deployments for the same reviewer can both read the same token; one rotates it and the other then fails, while concurrent first-use calls can also race into the unique constraint. Serialize the full lookup/refresh/persist sequence per normalized login across application instances, including the no-row case. 🤖 Prompt for AI agentsIn |
||
|
|
||
| if (existing.isPresent()) { | ||
| GitHubUserToken row = existing.get(); | ||
|
|
||
| if (row.getAccessTokenEnc() != null | ||
| && row.getAccessTokenExpiresAt() != null | ||
| && row.getAccessTokenExpiresAt().isAfter(now.plus(EXPIRY_MARGIN))) { | ||
| return tokenCipher.decrypt(row.getAccessTokenEnc()); | ||
| } | ||
|
|
||
| if (row.getRefreshTokenEnc() != null | ||
| && (row.getRefreshTokenExpiresAt() == null | ||
| || row.getRefreshTokenExpiresAt().isAfter(now))) { | ||
| GitHubUserTokenRecord refreshed = | ||
| oauthClient.refresh(tokenCipher.decrypt(row.getRefreshTokenEnc())); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @krusche [high] A rejected stored refresh token is propagated immediately, so signing in again cannot actually recover: Keycloak gets the new token, but every later call keeps retrying the stale encrypted database token. On 🤖 Prompt for AI agentsIn |
||
| persist(row, refreshed); | ||
| return refreshed.accessToken(); | ||
| } | ||
|
|
||
| return seedThenRefresh(row, githubLogin); | ||
| } | ||
|
|
||
| GitHubUserToken row = new GitHubUserToken(); | ||
| row.setGithubLogin(githubLogin); | ||
| return seedThenRefresh(row, githubLogin); | ||
| } | ||
|
|
||
| /** | ||
| * Seeds the refresh token from Keycloak and immediately exchanges it for a known-fresh access | ||
| * token (the seeded access token's real age is unknown, so we never trust it directly). | ||
| */ | ||
| private String seedThenRefresh(GitHubUserToken row, String githubLogin) throws IOException { | ||
| GitHubUserTokenRecord seeded = brokerClient.fetchStoredTokens(githubLogin); | ||
| GitHubUserTokenRecord refreshed = oauthClient.refresh(seeded.refreshToken()); | ||
| persist(row, refreshed); | ||
| return refreshed.accessToken(); | ||
| } | ||
|
|
||
| private void persist(GitHubUserToken row, GitHubUserTokenRecord record) { | ||
| row.setAccessTokenEnc(tokenCipher.encrypt(record.accessToken())); | ||
| row.setAccessTokenExpiresAt(record.accessTokenExpiresAt()); | ||
| if (record.refreshToken() != null) { | ||
| row.setRefreshTokenEnc(tokenCipher.encrypt(record.refreshToken())); | ||
| row.setRefreshTokenExpiresAt(record.refreshTokenExpiresAt()); | ||
| } | ||
| repository.save(row); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.naming.AbbreviationAsWordInNameCheck> reported by reviewdog 🐶
Abbreviation in name 'GitHubOAuthTokenClient' must contain no more than '1' consecutive capital letters.