Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,15 @@ services:
- GITHUB_APP_NAME=${GITHUB_APP_NAME}
- GITHUB_APP_ID=${GITHUB_APP_ID}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
# OAuth client secret of the login GitHub App — needed to refresh users' GitHub tokens.
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- GITHUB_INSTALLATION_ID=${GITHUB_INSTALLATION_ID}
- GITHUB_PRIVATE_KEY_PATH=${GITHUB_PRIVATE_KEY_PATH}
- SENTRY_DSN=${SENTRY_DSN}
- HELIOS_TOKEN_EXCHANGE_CLIENT=${HELIOS_TOKEN_EXCHANGE_CLIENT}
- HELIOS_TOKEN_EXCHANGE_SECRET=${HELIOS_TOKEN_EXCHANGE_SECRET}
# Base64 AES key for encrypting stored GitHub user tokens at rest.
- HELIOS_TOKEN_ENCRYPTION_KEY=${HELIOS_TOKEN_ENCRYPTION_KEY}
- DATA_SYNC_RUN_ON_STARTUP=${DATA_SYNC_RUN_ON_STARTUP:-true}
- CLEANUP_WORKFLOW_RUN_DRY_RUN=${CLEANUP_WORKFLOW_RUN_DRY_RUN}
# Orphan-branch sweep — off by default; enable per environment once the
Expand Down
27 changes: 25 additions & 2 deletions docs/contributor/keycloak_token_exchange.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,31 @@ A successful response will include an access token:
NOTE:
--------------

GitHub access tokens are valid for 8 hours. Identity provider tokens are not refreshed automatically in Keycloak.
In order to make sure you always have a valid token, limit the session to 8 hours.
GitHub App user access tokens are valid for 8 hours, and Keycloak does **not** refresh brokered
identity-provider tokens automatically (the ``github`` OAuth2 provider never refreshes on the
token-exchange path, in any Keycloak version). Plain token exchange therefore returns a token that
GitHub rejects with ``HTTP 401`` once it is older than 8 hours.

Helios works around this by refreshing GitHub tokens itself (see the ``auth.github.token`` package)
rather than relying on session limits:

#. It seeds a user's GitHub **refresh token** once from the broker retrieve-token endpoint
``GET /realms/<realm>/broker/github/token``, reached headlessly with an impersonation-exchanged
internal token. This requires the token-exchange client to hold the **retrieve-token** permission
on the ``github`` identity provider (Identity Providers → github → Permissions → the ``token``
permission → add the client policy). Without it the endpoint returns
``403 "Client [...] not authorized to retrieve tokens from identity provider [github]"``.
#. It then refreshes directly against GitHub
(``POST https://github.com/login/oauth/access_token`` with ``grant_type=refresh_token``) using the
App's ``client_id``/``client_secret``, caching the ~8h access token and persisting the rotated
refresh token (GitHub rotates refresh tokens on every use).

Required configuration for this to work:

* ``GITHUB_CLIENT_SECRET`` — the login GitHub App's OAuth client secret (with "Expire user
authorization tokens" enabled so refresh tokens are issued).
* ``HELIOS_TOKEN_ENCRYPTION_KEY`` — a base64 AES key; stored refresh tokens are encrypted at rest.
* The ``github`` IdP retrieve-token permission granted to the token-exchange client (above).

Security Considerations
-----------------------
Expand Down
292 changes: 292 additions & 0 deletions docs/superpowers/plans/2026-07-19-github-user-token-refresh.md

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 {

Copy link
Copy Markdown

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.


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")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 incorrect_client_credentials or an unsupported grant. Signing out cannot fix those, and this classification would also make the service's reseed path misleading. Map only refresh-token rejection codes to GitHubReauthRequiredException; keep configuration and malformed/transient responses as ordinary IOExceptions.

🤖 Prompt for AI agents

In server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubOAuthTokenClient.java, all OAuth error responses are incorrectly treated as requiring user login. Whitelist only refresh-token rejection errors for GitHubReauthRequiredException, map configuration and protocol errors to IOException, and add tests for both categories.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 agents

In server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenService.java, concurrent calls can consume the same single-use GitHub refresh token or race while creating the first row. Add database-backed per-login serialization around lookup, refresh, and persistence, handle the absent-row case, and add a concurrency test proving only one refresh grant runs.


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()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 bad_refresh_token, reseed from Keycloak and replace the row; only surface reauth if that fresh seed is also unavailable or rejected.

🤖 Prompt for AI agents

In server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenService.java, a rejected persisted refresh token permanently wins over a newer token stored by Keycloak after login. Catch the token-specific reauthorization exception from the stored-token refresh, reseed and persist through KeycloakBrokerTokenClient, and add a test that a post-login seed repairs the stale row.

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);
}
}
Loading
Loading