Skip to content

feat(auth): self-refreshing GitHub user tokens for deployment approvals - #1199

Open
krusche wants to merge 7 commits into
stagingfrom
feature/github-user-token-refresh
Open

feat(auth): self-refreshing GitHub user tokens for deployment approvals#1199
krusche wants to merge 7 commits into
stagingfrom
feature/github-user-token-refresh

Conversation

@krusche

@krusche krusche commented Jul 19, 2026

Copy link
Copy Markdown
Member

Motivation

Deployment approvals (both Helios' automatic approval and the in-app Approve/Decline buttons) act on GitHub using a GitHub user token obtained via Keycloak token-exchange. GitHub App user tokens expire after 8 hours, and Keycloak never refreshes brokered tokens — so a few hours after the reviewer last logged in, GitHub returns HTTP 401 and approvals fail (the "internal server error" reported on prod). It can never be "always automatic" as built, because the token only refreshes on interactive login.

This PR makes Helios own the refresh loop so a valid user token is always available, independent of when the user last logged in.

Approach (Helios-side refresh)

Keycloak config-only refresh is not possible for the github provider (confirmed against Keycloak source + issues), so:

  1. Seed each user's GitHub refresh token once from Keycloak's broker retrieve-token endpoint (GET /broker/github/token), reached headlessly via an impersonation token-exchange.
  2. Refresh directly against GitHub (grant_type=refresh_token) using the App's client id/secret, caching the ~8h access token and persisting the rotated refresh token each time (GitHub rotates them).
  3. On an unrecoverable refresh (revoked/expired) surface GitHubReauthRequiredException so the reviewer is told to sign in again.

Refresh tokens are 6-month credentials, so they're encrypted at rest (AES-GCM).

What's here (components)

  • TokenCipher — AES-GCM encrypt/decrypt; missing key degrades only token ops (never crashes boot, never stores plaintext).
  • github_user_token table (V59) + entity/repo — one encrypted row per GitHub login.
  • GitHubOAuthTokenClient — GitHub refresh grant (rotation-aware; distinguishes re-auth vs transient).
  • KeycloakBrokerTokenClient — seeds the refresh token via retrieve-token (parses JSON or form-encoded).
  • GitHubUserTokenService — cache → refresh → seed orchestration.
  • GitHubService rewired: approvals + release drafts now use getValidAccessToken(...).

⚠️ Required before merge/deploy (ops)

  • GITHUB_CLIENT_SECRET — the login GitHub App's OAuth client secret (App must have "Expire user authorization tokens" on so refresh tokens are issued).
  • HELIOS_TOKEN_ENCRYPTION_KEYopenssl rand -base64 32. (Missing key no longer crashes the app — it logs a warning and approvals fail until set — but it must be set for the feature to work.)
  • Keycloak: grant the helios-token-exchange client the retrieve-token permission on the github IdP (Identity Providers → github → Permissions → the token permission → add its policy). Confirmed needed — a read-only spike currently returns 403 "Client [helios-token-exchange] not authorized to retrieve tokens from identity provider [github]". (Headless impersonation token-exchange itself already returns 200.)

Relationship to #1198

#1198 (error surfacing) and this PR both touch GitHubService. The final actionable "GitHub authorization expired — sign in again" message for the re-auth case (mapping GitHubReauthRequiredException) lands as a small follow-up once #1198 merges — tracked in the plan (docs/superpowers/plans/2026-07-19-github-user-token-refresh.md, Task 9).

Testing

Unit tests for every new component (cipher round-trip; refresh parsing + rotation + re-auth vs transient; seed parsing for JSON and form-encoded bodies + the 403 path; service cache/refresh/seed/re-auth branches; rewired GitHubService). New embedded-Postgres integration test for the V59 store. Full :application-server:test suite green locally.

🤖 Generated with Claude Code

krusche and others added 7 commits July 19, 2026 18:42
Groundwork for Helios-side refresh of Keycloak-brokered GitHub tokens so
deployment approvals keep working past the 8h GitHub token lifetime.

- TokenCipher: AES-GCM encrypt/decrypt for tokens stored at rest, keyed by
  helios.tokenEncryptionKey (HELIOS_TOKEN_ENCRYPTION_KEY); fails fast on a blank
  key so refresh tokens (6-month credentials) are never persisted in plaintext.
- GitHubConfig: add the login App's OAuth clientSecret (GITHUB_CLIENT_SECRET),
  paired with the existing clientId, for the refresh_token grant.
- Wire GITHUB_CLIENT_SECRET + HELIOS_TOKEN_ENCRYPTION_KEY into prod/staging/dev
  profiles and compose.prod.yaml; dummy values in the test profile.
- Include the full implementation plan (docs/superpowers/plans/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the github_user_token store (one row per GitHub login) plus entity,
repository and an embedded-Postgres integration test. Token columns hold
AES-GCM ciphertext only. Foundation for the refresh loop that keeps deployment
approvals working past the 8h GitHub token lifetime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitHubOAuthTokenClient exchanges a stored refresh token for a fresh access token
directly against GitHub (grant_type=refresh_token), returning the rotated refresh
token to persist. OAuth error bodies (HTTP 200 + {"error":...}) surface as
GitHubReauthRequiredException (re-login needed); transport/5xx stay plain
IOException (transient).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- KeycloakBrokerTokenClient: seeds a user's GitHub refresh token via Keycloak's
  broker retrieve-token endpoint (impersonation token-exchange -> GET
  /broker/github/token), parsing either a JSON or form-encoded stored body.
  Requires the token-exchange client to hold retrieve-token permission on the
  github IdP (else 403).
- GitHubUserTokenService: hands out a valid GitHub user access token, resolving
  cache -> refresh (rotating + persisting) -> seed-then-refresh, and surfacing
  GitHubReauthRequiredException when no refresh path can succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…reshing token service

Replace the direct Keycloak token-exchange (which returns a token GitHub expires
after 8h and never refreshes) with GitHubUserTokenService.getValidAccessToken in
both reviewPendingDeployment and createReleaseOnBehalfOfUser. Approvals (auto and
in-app) and release drafts now get a token that is refreshed/re-seeded as needed,
so they keep working regardless of when the user last logged in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…missing

A blank HELIOS_TOKEN_ENCRYPTION_KEY no longer crashes the whole application at
boot (too large a blast radius for an approval-only feature, and staging
auto-deploys). Instead the app logs a loud warning at startup and encrypt/decrypt
throw on use, so a misconfigured instance degrades only GitHub-token operations
and still never stores plaintext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the "limit the session to 8 hours" workaround with the refresh model:
seed the refresh token from Keycloak's retrieve-token endpoint (needs the
retrieve-token permission on the github IdP), then refresh directly against
GitHub. Lists the required config (GITHUB_CLIENT_SECRET, HELIOS_TOKEN_ENCRYPTION_KEY).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 19, 2026 17:41
@krusche
krusche requested a review from a team as a code owner July 19, 2026 17:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codacy-production

codacy-production Bot commented Jul 19, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 2 minor

Alerts:
⚠ 2 issues (≤ 0 issues of at least minor severity)

Results:
2 new issues

Category Results
CodeStyle 1 minor
Comprehensibility 1 minor

View in Codacy

🔴 Metrics 54 complexity

Metric Results
Complexity ⚠️ 54 (≤ 20 complexity)

View in Codacy

🟢 Coverage 93.79% diff coverage · +0.58% coverage variation

Metric Results
Coverage variation +0.58% coverage variation (-1.00%)
Diff coverage 93.79% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (8afae1a) Report Missing Report Missing Report Missing
Head commit (fe60c56) 16137 (+248) 8751 (+226) 54.23% (+0.58%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#1199) 177 166 93.79%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

throw new IOException("Failed to exchange token for GitHub user: " + githubUserLogin);
}
// Fetch a valid GitHub user token, refreshing (or re-seeding) it as needed.
String userGithubToken = gitHubUserTokenService.getValidAccessToken(githubUserLogin);

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.coding.VariableDeclarationUsageDistanceCheck> reported by reviewdog 🐶
Distance between variable 'userGithubToken' declaration and its first usage is 6, but allowed 3. Consider making that variable final if you still need to store its value in advance (before method calls that might have side effects on the original value).

@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.

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

class GitHubOAuthTokenClientTest {

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 'GitHubOAuthTokenClientTest' must contain no more than '1' consecutive capital letters.

@Claudia-Anthropica Claudia-Anthropica left a comment

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 The encrypted persistence and component-level tests are a solid base, but the rotation path needs per-user serialization and a recovery path after reauthentication before this is safe to merge. OAuth configuration failures also need to remain distinct from user reauthorization, and the agent-directed instruction in the plan should be removed. Codacy is currently failing as well.

@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.

&& (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.

}

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.

@@ -0,0 +1,292 @@
# GitHub User-Token Refresh Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

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] This line is an instruction aimed directly at automated agents, including a required tool/workflow choice. Repository documentation should describe the implementation for readers without attempting to control an agent's execution environment; please remove this directive or rewrite it as neutral plan context.

🤖 Prompt for AI agents

In docs/superpowers/plans/2026-07-19-github-user-token-refresh.md, line 3 directs automated agents to use specific skills and workflows. Remove the agent-directed directive and leave only neutral, human-readable implementation-plan context.

@github-actions

Copy link
Copy Markdown

There hasn't been any activity on this pull request recently. Therefore, this pull request has been automatically marked as stale and will be closed if no further activity occurs within seven days. Thank you for your contributions.

@github-actions github-actions Bot added the stale label Jul 27, 2026
@github-actions github-actions Bot closed this Aug 11, 2026
@krusche

krusche commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

we should still work on this and get it fixed: not stale

@krusche krusche reopened this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants