diff --git a/compose.prod.yaml b/compose.prod.yaml index 477494f96..377775df4 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -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 diff --git a/docs/contributor/keycloak_token_exchange.rst b/docs/contributor/keycloak_token_exchange.rst index 03a5ea72b..e87a45fb2 100644 --- a/docs/contributor/keycloak_token_exchange.rst +++ b/docs/contributor/keycloak_token_exchange.rst @@ -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//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 ----------------------- diff --git a/docs/superpowers/plans/2026-07-19-github-user-token-refresh.md b/docs/superpowers/plans/2026-07-19-github-user-token-refresh.md new file mode 100644 index 000000000..60a6a323e --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-github-user-token-refresh.md @@ -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. + +**Goal:** Make Helios always hold a valid GitHub *user* access token for deployment approvals, by owning the refresh loop itself — so auto- and in-app approval keep working regardless of how long ago the reviewer logged in. + +**Architecture:** Helios stops depending on Keycloak's (non-refreshing) token-exchange for the live GitHub token. Instead it persists each user's GitHub **refresh token** (seeded once from Keycloak's broker *retrieve-token* endpoint), then refreshes directly against GitHub's OAuth token endpoint on demand, caching the ~8 h access token and rotating the refresh token on every use. Refresh tokens are 6-month credentials, so they are encrypted at rest. + +**Tech Stack:** Java 21+, Spring Boot, Spring Data JPA, Flyway, OkHttp, Jackson, JUnit 5 + Mockito, zonky embedded Postgres (integration tests), Keycloak 26.1.3, GitHub App OAuth. + +## Global Constraints + +- Java source compiled on JDK 25; language level per existing `build.gradle` — match surrounding code. +- Checkstyle (`server/checkstyle.xml`, Google-based): **max line length 100**, 2-space indent, alphabetical imports (static first), Javadoc on public types. +- Dependencies pinned exactly — **never** use `^`/version ranges; reuse libraries already on the classpath (OkHttp, Jackson) — add none. +- Flyway migrations are immutable and sequential; next free version is **V59**. Never edit an applied migration. +- Deployment-referenced integrity: do not weaken existing approval-path behaviour (audit rows, `FAILED_AT_GITHUB`, self-review checks). +- All new secrets are provided via environment variables (compose), never committed. +- Every task ends green: `server/gradlew -p server :application-server:test` (targeted with `--tests`). + +--- + +## Prerequisites (ops / prod config — NOT code; do before Task 2) + +These are operator steps. Document exact values in the PR description; do not hard-code secrets. + +**P1. GitHub App OAuth client secret.** The refresh grant needs the login GitHub App's `client_id` + `client_secret`. Helios already has `GITHUB_CLIENT_ID`; provision the matching secret as `GITHUB_CLIENT_SECRET`. Confirm the App has **"Expire user authorization tokens" enabled** (Developer settings → the App → Optional features) — that is what makes GitHub issue refresh tokens at all. + +**P2. Token-encryption key.** Provision `HELIOS_TOKEN_ENCRYPTION_KEY` = base64-encoded 256-bit key (`openssl rand -base64 32`). App fails fast at startup if unset (see Task 3) so refresh tokens are never written in plaintext. + +**P3. Keycloak retrieve-token permission — CONFIRMED by the Task-1 spike (2026-07-19).** In realm `helios`, the `github` IdP already has `Store tokens = ON`, and a headless impersonation token-exchange for a user already succeeds (spike Step A → HTTP 200). The only missing grant: **Identity Providers → github → Permissions → enable, then the `token` (retrieve-token) permission → add a client policy for `helios-token-exchange`.** Without it the retrieve endpoint returns exactly: + +``` +StepB(retrieve-token /broker/github/token) http=403 +{"errorMessage":"Client [helios-token-exchange] not authorized to retrieve tokens from identity provider [github]."} +``` + +This is a mirror of the impersonation permission that already lets Step A work; low-risk and reversible. After granting it, re-run the spike (`scratchpad/kc_spike.sh`) — Step B must return 200 with a body containing `refresh_token` — before implementing Task 5. + +--- + +## Risk & the Task-1 spike + +The entire "seed via retrieve-token" approach hinges on Helios being able to obtain a user's stored GitHub **refresh token** *headlessly* (no browser session). Research confirms the retrieve-token endpoint returns the full stored token JSON (incl. `refresh_token`) for the `github` provider, and that a bearer carrying `broker`/`read-token` can call it — but the headless/impersonation specifics must be proven on our Keycloak before building on them. **Task 1 is a throwaway spike that proves this end-to-end.** If it fails, stop and escalate (fallback = Keycloak event-listener SPI, a different plan). + +--- + +## File Structure + +- `server/application-server/src/main/resources/db/migration/V59__create_github_user_token.sql` — token store table. +- `.../auth/github/token/GitHubUserToken.java` — JPA entity (one row per GitHub login). +- `.../auth/github/token/GitHubUserTokenRepository.java` — Spring Data repo. +- `.../auth/github/token/TokenCipher.java` — AES-GCM encrypt/decrypt of token strings. +- `.../auth/github/token/GitHubOAuthTokenClient.java` — GitHub refresh-grant HTTP call. +- `.../auth/github/token/KeycloakBrokerTokenClient.java` — seed refresh token via Keycloak retrieve-token. +- `.../auth/github/token/GitHubUserTokenService.java` — orchestrates cache → refresh → seed; the public API. +- `.../auth/github/token/GitHubReauthRequiredException.java` — extends `IOException`; signals "user must re-login". +- `.../auth/github/token/GitHubUserTokenRecord.java` — immutable value carrying tokens + expiries. +- Modify `.../github/GitHubService.java` — use `GitHubUserTokenService` instead of `GitHubAuthBroker.exchangeToken`. +- Modify `.../deployment/approval/DeploymentReviewActionService.java` — treat `GitHubReauthRequiredException` like the 401 case (actionable reason). +- Modify `.../github/GitHubConfig.java` (+ `application.yml`, `compose.prod.yaml`) — new config keys. +- Tests alongside each unit; one integration test for the repo/migration + cipher round-trip. + +--- + +### Task 1: Spike — prove headless refresh-token retrieval (throwaway) + +**Files:** none committed (a scratch script run against staging Keycloak). + +**Interfaces:** Produces a documented yes/no on P3 + the exact request shapes Task 5 will encode. + +- [ ] **Step 1:** Mint an *internal* Keycloak access token for a known GitHub user via impersonation token-exchange (no `requested_issuer`), using `HELIOS_TOKEN_EXCHANGE_CLIENT`/`_SECRET`: +```bash +curl -s -XPOST "$ISSUER/protocol/openid-connect/token" \ + -d client_id=$TEC -d client_secret=$TES \ + -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ + -d requested_subject= \ + -d requested_token_type=urn:ietf:params:oauth:token-type:access_token +``` +- [ ] **Step 2:** Call retrieve-token with that bearer and confirm `refresh_token` is present: +```bash +curl -s "$ISSUER/broker/github/token" -H "Authorization: Bearer " +``` +Expected: JSON containing `access_token`, `refresh_token`, `refresh_token_expires_in`. +- [ ] **Step 3:** If `refresh_token` is absent or the call 403s, STOP — fix P3 (add `read-token`) or escalate to the SPI fallback. Record the working request/response shapes in the PR. + +--- + +### Task 2: Config plumbing for GitHub OAuth + encryption key + +**Files:** +- Modify: `server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubConfig.java` +- Modify: `server/application-server/src/main/resources/application.yml` +- Modify: `compose.prod.yaml` + +**Interfaces:** +- Produces: `GitHubConfig.getOauthClientId()`, `GitHubConfig.getOauthClientSecret()` (String getters via Lombok `@Getter`). + +- [ ] **Step 1:** Add fields to `GitHubConfig` (Lombok `@Getter` already on the class-level fields it exposes; add `@Getter` per field to match existing style): +```java + @Getter + @Value("${github.oauthClientId:${github.clientId}}") + private String oauthClientId; + + @Getter + @Value("${github.oauthClientSecret:#{null}}") + private String oauthClientSecret; +``` +- [ ] **Step 2:** In `application.yml`, under the existing `github:` mapping, add: +```yaml + oauthClientId: ${GITHUB_CLIENT_ID:} + oauthClientSecret: ${GITHUB_CLIENT_SECRET:} + tokenEncryptionKey: ${HELIOS_TOKEN_ENCRYPTION_KEY:} +``` +- [ ] **Step 3:** In `compose.prod.yaml` (application-server `environment:`), add `GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}` and `HELIOS_TOKEN_ENCRYPTION_KEY=${HELIOS_TOKEN_ENCRYPTION_KEY}` near the existing GitHub vars. +- [ ] **Step 4:** Build to verify wiring: `server/gradlew -p server :application-server:compileJava` → BUILD SUCCESSFUL. +- [ ] **Step 5:** Commit: `chore(auth): add GitHub OAuth client-secret and token-encryption config`. + +--- + +### Task 3: `TokenCipher` (AES-GCM at rest) + +**Files:** +- Create: `.../auth/github/token/TokenCipher.java` +- Test: `.../auth/github/token/TokenCipherTest.java` + +**Interfaces:** +- Produces: `String TokenCipher.encrypt(String plaintext)`, `String TokenCipher.decrypt(String stored)` (Base64 `iv:ciphertext`). Constructed from `@Value("${github.tokenEncryptionKey}")`; throws `IllegalStateException` at construction if the key is blank. + +- [ ] **Step 1:** Write the failing test: +```java +@Test +void encryptThenDecryptRoundTrips() { + TokenCipher c = new TokenCipher("Base64Key32BytesElided=..."); // 32-byte base64 + String enc = c.encrypt("ghr_secret"); + assertNotEquals("ghr_secret", enc); + assertEquals("ghr_secret", c.decrypt(enc)); +} + +@Test +void blankKeyFailsFast() { + assertThrows(IllegalStateException.class, () -> new TokenCipher(" ")); +} +``` +- [ ] **Step 2:** Run → FAIL (class missing). `... --tests "*TokenCipherTest"`. +- [ ] **Step 3:** Implement AES/GCM/NoPadding (12-byte random IV per encrypt, 128-bit tag; store `base64(iv) + ":" + base64(ct)`; key = `Base64.getDecoder().decode(keyProp)` → `SecretKeySpec(…, "AES")`; blank key → `IllegalStateException`). Use `javax.crypto.*` + `java.security.SecureRandom` only. +- [ ] **Step 4:** Run → PASS. +- [ ] **Step 5:** Commit: `feat(auth): add TokenCipher for encrypting stored GitHub tokens`. + +--- + +### Task 4: Token store — migration, entity, repository, integration test + +**Files:** +- Create: `db/migration/V59__create_github_user_token.sql` +- Create: `.../auth/github/token/GitHubUserToken.java`, `GitHubUserTokenRepository.java` +- Create: `.../auth/github/token/GitHubUserTokenRecord.java` +- Test: `.../auth/github/token/GitHubUserTokenRepositoryIT.java` (zonky, mirror `WorkflowRunRetentionIntegrationTest` setup) + +**Interfaces:** +- Produces: entity `GitHubUserToken` (fields `id`, `githubLogin`, `accessTokenEnc`, `refreshTokenEnc`, `accessTokenExpiresAt`, `refreshTokenExpiresAt`, `updatedAt`); `Optional GitHubUserTokenRepository.findByGithubLogin(String)`; record `GitHubUserTokenRecord(String accessToken, OffsetDateTime accessExpiresAt, String refreshToken, OffsetDateTime refreshExpiresAt)`. + +- [ ] **Step 1:** Write migration: +```sql +CREATE TABLE public.github_user_token ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + github_login varchar(255) NOT NULL UNIQUE, + access_token_enc text, + refresh_token_enc text, + access_token_expires_at timestamp(6) with time zone, + refresh_token_expires_at timestamp(6) with time zone, + updated_at timestamp(6) with time zone NOT NULL +); +``` +- [ ] **Step 2:** Write entity + repository (JPA, matching repo conventions; `@Column(name = "...")` for snake_case). +- [ ] **Step 3:** Write the failing IT: insert via `save`, `findByGithubLogin` returns it; unique constraint on `github_login` enforced. +- [ ] **Step 4:** Run → PASS. `... --tests "*GitHubUserTokenRepositoryIT"`. +- [ ] **Step 5:** Commit: `feat(auth): persist per-user GitHub tokens (V59)`. + +--- + +### Task 5: `KeycloakBrokerTokenClient` (seed refresh token) + +**Files:** +- Create: `.../auth/github/token/KeycloakBrokerTokenClient.java` +- Test: `.../auth/github/token/KeycloakBrokerTokenClientTest.java` (OkHttp mocked, mirror `GitHubServiceTest` style) + +**Interfaces:** +- Consumes: `issuerUri`, token-exchange client id/secret (same `@Value`s as `GitHubAuthBroker`), `OkHttpClient`, `ObjectMapper`. +- Produces: `GitHubUserTokenRecord KeycloakBrokerTokenClient.fetchStoredTokens(String githubLogin) throws IOException` — mints an internal impersonation token then GETs `/broker/github/token`, mapping `access_token`/`expires_in`/`refresh_token`/`refresh_token_expires_in` (relative to `now`) into the record. Throws `GitHubReauthRequiredException` if no refresh token is returned (user never linked / must re-login). + +- [ ] **Step 1:** Write failing tests: (a) happy path returns record with refresh token; (b) missing `refresh_token` in body → `GitHubReauthRequiredException`; (c) non-2xx retrieve → `IOException`. +- [ ] **Step 2:** Run → FAIL. +- [ ] **Step 3:** Implement using the exact request shapes confirmed in Task 1. +- [ ] **Step 4:** Run → PASS. +- [ ] **Step 5:** Commit: `feat(auth): seed GitHub refresh tokens from Keycloak retrieve-token`. + +--- + +### Task 6: `GitHubOAuthTokenClient` (refresh grant) + +**Files:** +- Create: `.../auth/github/token/GitHubOAuthTokenClient.java`, `GitHubReauthRequiredException.java` +- Test: `.../auth/github/token/GitHubOAuthTokenClientTest.java` + +**Interfaces:** +- Consumes: `GitHubConfig` (oauth client id/secret), `OkHttpClient`, `ObjectMapper`. +- Produces: `GitHubUserTokenRecord GitHubOAuthTokenClient.refresh(String refreshToken) throws IOException`. On GitHub error bodies (`bad_refresh_token`, `bad_verification_code`, `unauthorized`) throws `GitHubReauthRequiredException`. `GitHubReauthRequiredException extends IOException`. + +- [ ] **Step 1:** Write failing tests: (a) `POST https://github.com/login/oauth/access_token` with `grant_type=refresh_token` → parses new `access_token`/`expires_in`/`refresh_token`/`refresh_token_expires_in`; (b) `{"error":"bad_refresh_token"}` → `GitHubReauthRequiredException`; assert request carries `client_id`/`client_secret`/`refresh_token` form fields and `Accept: application/json`. +- [ ] **Step 2:** Run → FAIL. +- [ ] **Step 3:** Implement (FormBody; expiries = `now + expires_in`/`refresh_token_expires_in`). +- [ ] **Step 4:** Run → PASS. +- [ ] **Step 5:** Commit: `feat(auth): add GitHub OAuth refresh-token client`. + +--- + +### Task 7: `GitHubUserTokenService` (cache → refresh → seed orchestration) + +**Files:** +- Create: `.../auth/github/token/GitHubUserTokenService.java` +- Test: `.../auth/github/token/GitHubUserTokenServiceTest.java` + +**Interfaces:** +- Consumes: `GitHubUserTokenRepository`, `TokenCipher`, `KeycloakBrokerTokenClient`, `GitHubOAuthTokenClient`, a `Clock` (inject for testability). +- Produces: `String GitHubUserTokenService.getValidAccessToken(String githubLogin) throws IOException`. + +Logic (encode exactly): +1. `row = repo.findByGithubLogin(login)`. +2. If `row` present and `accessTokenExpiresAt > now + 60s` → return `cipher.decrypt(accessTokenEnc)`. +3. Else if `row` present and `refreshTokenEnc != null` and `refreshTokenExpiresAt > now` → `rec = oauth.refresh(cipher.decrypt(refreshTokenEnc))`; persist (encrypt both, rotate); return `rec.accessToken()`. +4. Else → `rec = broker.fetchStoredTokens(login)`; if `rec.accessToken()` still valid return it after persisting; otherwise immediately `rec = oauth.refresh(rec.refreshToken())`; persist; return. +5. `GitHubReauthRequiredException` propagates. Persistence always encrypts via `TokenCipher` and writes `updatedAt = now`. Use `@Transactional` per public call; refresh HTTP happens inside but is idempotent enough (rotation persisted immediately after). + +- [ ] **Step 1:** Write failing tests covering each branch: fresh-cache hit (no network); expired-access→refresh (rotates + persists new refresh); no-row→seed→return; refresh throws `GitHubReauthRequiredException` propagates; expired refresh → re-seed. +- [ ] **Step 2:** Run → FAIL. +- [ ] **Step 3:** Implement per logic above. +- [ ] **Step 4:** Run → PASS. +- [ ] **Step 5:** Commit: `feat(auth): GitHubUserTokenService with refresh + rotation`. + +--- + +### Task 8: Rewire `GitHubService` to the token service + +**Files:** +- Modify: `.../github/GitHubService.java` (`reviewPendingDeployment`, ~line 547) +- Modify: `.../github/GitHubServiceTest.java` + +**Interfaces:** +- Consumes: `GitHubUserTokenService.getValidAccessToken`. +- Produces: unchanged public `approveDeploymentOnBehalfOfUser` / `rejectDeploymentOnBehalfOfUser` signatures. + +- [ ] **Step 1:** Update `GitHubServiceTest`: replace `gitHubAuthBroker.exchangeToken(...)` stubbing with `gitHubUserTokenService.getValidAccessToken(login)` returning `"user-token"`; the null/failure case now stubs `getValidAccessToken` to throw `GitHubReauthRequiredException` and asserts it surfaces (the endpoint still 401s → `GitHubReviewException`). Keep the HTTP-500 → `GitHubReviewException` case. +- [ ] **Step 2:** Run → FAIL (constructor/field mismatch). +- [ ] **Step 3:** Inject `GitHubUserTokenService`; replace the `gitHubAuthBroker.exchangeToken(...)` block in `reviewPendingDeployment` with `String userGithubToken = gitHubUserTokenService.getValidAccessToken(githubUserLogin);`. Remove the now-unused `GitHubAuthBroker` dependency **only if** it has no other callers (it does not — verified). Keep the `Bearer` header logic. +- [ ] **Step 4:** Run → PASS: `... --tests "*GitHubServiceTest"`. +- [ ] **Step 5:** Commit: `refactor(github): approve deployments with the self-refreshing token service`. + +--- + +### Task 9: Actionable reason for re-auth in the approval path + +**Files:** +- Modify: `.../deployment/approval/DeploymentReviewActionService.java` (`gitHubFailureReason`, from PR #1198) +- Modify: `.../deployment/approval/DeploymentReviewActionServiceTest.java` + +- [ ] **Step 1:** Add a failing test: `getValidAccessToken`→`GitHubReviewException`(401) OR `GitHubReauthRequiredException` both yield the "expired / sign in again" reason and a `FAILED_AT_GITHUB` row. +- [ ] **Step 2:** Run → FAIL. +- [ ] **Step 3:** Extend `gitHubFailureReason(IOException e)` to also match `e instanceof GitHubReauthRequiredException`. +- [ ] **Step 4:** Run → PASS. +- [ ] **Step 5:** Commit: `feat(deployment): actionable re-auth message when the GitHub token cannot be refreshed`. + +--- + +### Task 10: Full suite + docs + +- [ ] **Step 1:** `server/gradlew -p server :application-server:test` → BUILD SUCCESSFUL. +- [ ] **Step 2:** Update `docs/contributor/keycloak_token_exchange.rst`: replace the "limit the session to 8 hours" note with the new refresh model (Helios seeds via retrieve-token and refreshes against GitHub; requires `read-token`, `GITHUB_CLIENT_SECRET`, `HELIOS_TOKEN_ENCRYPTION_KEY`). +- [ ] **Step 3:** Commit: `docs(auth): document Helios-side GitHub token refresh`. +- [ ] **Step 4:** Open PR to `staging` with the P1–P3 ops checklist in the body. + +--- + +## Self-Review + +**Spec coverage:** always-valid token → Tasks 5–8; refresh + rotation → Tasks 6–7; seed via retrieve-token → Tasks 1,5; encryption at rest → Tasks 3–4,7; surface re-auth need → Tasks 6,9 (+ PR #1198); prod config → Task 2 + P1–P3; auto-approval path benefits transparently via Task 8 (no change needed — `ApprovalService` calls the same `GitHubService` method). + +**Placeholder scan:** Keycloak request specifics in Task 5 are anchored to Task 1's empirically-confirmed shapes rather than guessed; no "TODO"/"handle errors" placeholders. + +**Type consistency:** `GitHubUserTokenRecord(accessToken, accessExpiresAt, refreshToken, refreshExpiresAt)` used identically in Tasks 4–7; `getValidAccessToken(String):String` consistent across Tasks 7–8; `GitHubReauthRequiredException extends IOException` used in Tasks 5,6,8,9. + +**Open risk:** Task 1 gates the whole plan. If headless retrieve-token can't yield the refresh token, switch to the event-listener SPI approach (separate plan) before continuing. diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubOAuthTokenClient.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubOAuthTokenClient.java new file mode 100644 index 000000000..6a3538ea7 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubOAuthTokenClient.java @@ -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 new refresh token + * the caller must persist. + * + *

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")) { + 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); + } + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubReauthRequiredException.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubReauthRequiredException.java new file mode 100644 index 000000000..9f664aeb3 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubReauthRequiredException.java @@ -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. + * + *

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); + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserToken.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserToken.java new file mode 100644 index 000000000..28ba93374 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserToken.java @@ -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(); + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRecord.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRecord.java new file mode 100644 index 000000000..7bded1008 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRecord.java @@ -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}. + * + *

{@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) {} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRepository.java new file mode 100644 index 000000000..9fbfab568 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRepository.java @@ -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 { + + Optional findByGithubLogin(String githubLogin); +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenService.java new file mode 100644 index 000000000..9463e209b --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenService.java @@ -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 user access token for {@code githubLogin}, refreshing + * as needed, so deployment approvals no longer depend on how recently the user logged in. + * + *

Resolution order: + * + *

    + *
  1. a cached access token that is still comfortably valid → return it (no network); + *
  2. otherwise a stored, unexpired refresh token → refresh against GitHub, persist the rotated + * tokens, return the new access token; + *
  3. otherwise seed the refresh token from Keycloak (retrieve-token), then refresh. + *
+ * + *

GitHub rotates refresh tokens on use, so every refresh persists the new 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 existing = repository.findByGithubLogin(githubLogin); + + 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())); + 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); + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/KeycloakBrokerTokenClient.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/KeycloakBrokerTokenClient.java new file mode 100644 index 000000000..d89f86d66 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/KeycloakBrokerTokenClient.java @@ -0,0 +1,167 @@ +package de.tum.cit.aet.helios.auth.github.token; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.log4j.Log4j2; +import okhttp3.FormBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Seeds a user's GitHub refresh token from Keycloak's identity-broker retrieve-token endpoint — + * the one place that returns the stored GitHub token including the refresh token (plain + * token-exchange returns only the access token, and Keycloak never refreshes GitHub tokens itself). + * + *

Two steps: (1) mint an internal Keycloak access token for the user via impersonation + * token-exchange (no {@code requested_issuer}); (2) call {@code GET /broker/github/token} with it. + * Step 2 requires the token-exchange client to hold the retrieve-token permission on the {@code + * github} IdP (otherwise Keycloak returns 403). + * + *

Keycloak may hand the stored GitHub token back as JSON or as the raw form-encoded body GitHub + * originally returned, so both are parsed. + */ +@Log4j2 +@Component +public class KeycloakBrokerTokenClient { + + private final OkHttpClient okHttpClient; + private final ObjectMapper objectMapper; + private final String issuerUri; + private final String tokenExchangeClient; + private final String tokenExchangeSecret; + + public KeycloakBrokerTokenClient( + OkHttpClient okHttpClient, + ObjectMapper objectMapper, + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri, + @Value("${github.tokenExchangeClientId}") String tokenExchangeClient, + @Value("${github.tokenExchangeClientSecret}") String tokenExchangeSecret) { + this.okHttpClient = okHttpClient; + this.objectMapper = objectMapper; + this.issuerUri = issuerUri; + this.tokenExchangeClient = tokenExchangeClient; + this.tokenExchangeSecret = tokenExchangeSecret; + } + + /** + * Retrieves the stored GitHub tokens for {@code githubLogin}. + * + * @throws GitHubReauthRequiredException if Keycloak holds no refresh token for the user (they + * must sign in through GitHub again) + * @throws IOException on transport failure or a non-2xx Keycloak response + */ + public GitHubUserTokenRecord fetchStoredTokens(String githubLogin) throws IOException { + String internalToken = exchangeForInternalToken(githubLogin); + String storedBody = retrieveStoredGitHubToken(internalToken); + Map fields = parseTokenBody(storedBody); + + String refreshToken = fields.get("refresh_token"); + if (refreshToken == null || refreshToken.isBlank()) { + throw new GitHubReauthRequiredException( + "Keycloak returned no GitHub refresh token for @" + githubLogin + + "; the user must sign in to Helios again."); + } + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime accessExpiry = now.plusSeconds(parseSeconds(fields.get("expires_in"))); + OffsetDateTime refreshExpiry = + fields.containsKey("refresh_token_expires_in") + ? now.plusSeconds(parseSeconds(fields.get("refresh_token_expires_in"))) + : null; + return new GitHubUserTokenRecord( + fields.get("access_token"), accessExpiry, refreshToken, refreshExpiry); + } + + private String exchangeForInternalToken(String githubLogin) throws IOException { + FormBody form = + new FormBody.Builder() + .add("client_id", tokenExchangeClient) + .add("client_secret", tokenExchangeSecret) + .add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + .add("requested_subject", githubLogin) + .add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") + .build(); + Request request = + new Request.Builder().url(issuerUri + "/protocol/openid-connect/token").post(form).build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String content = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + throw new IOException("Keycloak token exchange failed: HTTP " + response.code()); + } + JsonNode json = objectMapper.readTree(content); + if (!json.hasNonNull("access_token") || json.get("access_token").asText().isBlank()) { + throw new IOException("Keycloak token exchange returned no access token."); + } + return json.get("access_token").asText(); + } + } + + private String retrieveStoredGitHubToken(String internalToken) throws IOException { + Request request = + new Request.Builder() + .url(issuerUri + "/broker/github/token") + .get() + .header("Authorization", "Bearer " + internalToken) + .build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + ResponseBody body = response.body(); + String content = body == null ? "" : body.string(); + if (!response.isSuccessful()) { + throw new IOException( + "Keycloak retrieve-token failed: HTTP " + response.code() + + " (does the token-exchange client have retrieve-token permission on the github " + + "IdP?)"); + } + return content; + } + } + + /** Parses the stored GitHub token, accepting either a JSON object or a form-encoded string. */ + private Map parseTokenBody(String body) throws IOException { + Map fields = new HashMap<>(); + String trimmed = body == null ? "" : body.trim(); + if (trimmed.startsWith("{")) { + JsonNode json = objectMapper.readTree(trimmed); + json.fields() + .forEachRemaining( + entry -> { + if (entry.getValue().isValueNode()) { + fields.put(entry.getKey(), entry.getValue().asText()); + } + }); + } else { + for (String pair : trimmed.split("&")) { + int eq = pair.indexOf('='); + if (eq > 0) { + String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8); + String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + fields.put(key, value); + } + } + } + return fields; + } + + private static long parseSeconds(String value) { + if (value == null || value.isBlank()) { + return 0L; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return 0L; + } + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/TokenCipher.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/TokenCipher.java new file mode 100644 index 000000000..a341fba80 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/token/TokenCipher.java @@ -0,0 +1,88 @@ +package de.tum.cit.aet.helios.auth.github.token; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * AES-GCM encryption for GitHub tokens persisted at rest. The stored form is + * {@code base64(iv):base64(ciphertext+tag)} with a fresh random IV per call. + * + *

The key comes from {@code helios.tokenEncryptionKey} (env + * {@code HELIOS_TOKEN_ENCRYPTION_KEY}) — a base64-encoded 128/192/256-bit AES key. A missing key + * is not fatal at startup (that would take the whole app down for an approval-only + * feature); instead {@link #encrypt}/{@link #decrypt} fail loudly on use, so a misconfigured + * instance degrades only GitHub-token operations and never writes plaintext. + */ +@Log4j2 +@Component +public class TokenCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH_BYTES = 12; + private static final int TAG_LENGTH_BITS = 128; + + private final SecretKeySpec key; + private final SecureRandom secureRandom = new SecureRandom(); + + public TokenCipher(@Value("${helios.tokenEncryptionKey:}") String base64Key) { + if (base64Key == null || base64Key.isBlank()) { + log.warn( + "helios.tokenEncryptionKey (HELIOS_TOKEN_ENCRYPTION_KEY) is not set; GitHub user-token " + + "storage is disabled and deployment approvals via Helios will fail until it is " + + "configured with a base64 AES key."); + this.key = null; + } else { + this.key = new SecretKeySpec(Base64.getDecoder().decode(base64Key.trim()), "AES"); + } + } + + private SecretKeySpec requireKey() { + if (key == null) { + throw new IllegalStateException( + "helios.tokenEncryptionKey (HELIOS_TOKEN_ENCRYPTION_KEY) is not configured; cannot " + + "encrypt or decrypt GitHub tokens."); + } + return key; + } + + /** Encrypts {@code plaintext}, returning {@code base64(iv):base64(ciphertext)}. */ + public String encrypt(String plaintext) { + try { + byte[] iv = new byte[IV_LENGTH_BYTES]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + Base64.Encoder encoder = Base64.getEncoder(); + return encoder.encodeToString(iv) + ":" + encoder.encodeToString(ciphertext); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to encrypt token", e); + } + } + + /** Reverses {@link #encrypt(String)}. */ + public String decrypt(String stored) { + int separator = stored.indexOf(':'); + if (separator < 0) { + throw new IllegalArgumentException("Malformed encrypted token (missing IV separator)"); + } + try { + Base64.Decoder decoder = Base64.getDecoder(); + byte[] iv = decoder.decode(stored.substring(0, separator)); + byte[] ciphertext = decoder.decode(stored.substring(separator + 1)); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Failed to decrypt token", e); + } + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubConfig.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubConfig.java index 02afd417c..64ce0259c 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubConfig.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubConfig.java @@ -23,9 +23,20 @@ public class GitHubConfig { @Value("${github.appId:#{null}}") private Long appId; + @Getter @Value("${github.clientId}") private String clientId; + /** + * OAuth client secret of the GitHub App used for user login (paired with {@link #clientId}). + * Needed for the {@code grant_type=refresh_token} call that keeps a user's GitHub token alive; + * see {@code auth.github.token}. Optional so instances that do not use in-app approvals still + * start. + */ + @Getter + @Value("${github.clientSecret:#{null}}") + private String clientSecret; + @Value("${github.installationId:#{null}}") private Long installationId; diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java index 2eaad7cc0..dc7d7bef8 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java @@ -4,8 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.cit.aet.helios.auth.AuthService; -import de.tum.cit.aet.helios.auth.github.GitHubAuthBroker; -import de.tum.cit.aet.helios.auth.github.TokenExchangeResponse; +import de.tum.cit.aet.helios.auth.github.token.GitHubUserTokenService; import de.tum.cit.aet.helios.deployment.github.GitHubDeploymentDto; import de.tum.cit.aet.helios.environment.github.GitHubEnvironmentApiResponse; import de.tum.cit.aet.helios.environment.github.GitHubEnvironmentDto; @@ -66,7 +65,7 @@ public record EnvironmentFetchResult(List environments, bo private final ObjectMapper objectMapper; private final OkHttpClient okHttpClient; private final AuthService authService; - private final GitHubAuthBroker gitHubAuthBroker; + private final GitHubUserTokenService gitHubUserTokenService; private final GitHubClientManager clientManager; private GHOrganization gitHubOrganization; @@ -544,13 +543,8 @@ private void reviewPendingDeployment( RequestBody requestBody = RequestBody.create(jsonPayload, MediaType.get("application/json; charset=utf-8")); - TokenExchangeResponse tokenExchangeResponse = - this.gitHubAuthBroker.exchangeToken(githubUserLogin); - if (tokenExchangeResponse == null) { - log.error("Token exchange response is null for {}", githubUserLogin); - throw new IOException("Failed to exchange GitHub token for user: " + githubUserLogin); - } - String userGithubToken = tokenExchangeResponse.getAccessToken(); + // Fetch a valid GitHub user token, refreshing (or re-seeding) it as needed. + String userGithubToken = gitHubUserTokenService.getValidAccessToken(githubUserLogin); Request request = new Request.Builder() @@ -679,13 +673,8 @@ public GHRelease createReleaseOnBehalfOfUser( String githubUserLogin) throws IOException { - // Exchange token for the user - TokenExchangeResponse tokenExchangeResponse = - this.gitHubAuthBroker.exchangeToken(githubUserLogin); - if (tokenExchangeResponse == null) { - log.error("Token exchange response is null"); - 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); // Construct the request payload Map requestPayload = new HashMap<>(); @@ -713,8 +702,6 @@ public GHRelease createReleaseOnBehalfOfUser( RequestBody requestBody = RequestBody.create(jsonPayload, MediaType.get("application/json; charset=utf-8")); - String userGithubToken = tokenExchangeResponse.getAccessToken(); - Request request = new Request.Builder() .url(url) diff --git a/server/application-server/src/main/resources/application-dev.yml b/server/application-server/src/main/resources/application-dev.yml index 5e04af68f..3eb2d6f7a 100644 --- a/server/application-server/src/main/resources/application-dev.yml +++ b/server/application-server/src/main/resources/application-dev.yml @@ -30,6 +30,8 @@ helios: secretKey: ${HELIOS_LOCAL_SECRET_KEY:} clientBaseUrl: "http://localhost:4200" developers: ${HELIOS_DEVELOPERS_GITHUB_USERNAMES:} + # Base64 AES key for encrypting stored GitHub user tokens at rest (see auth.github.token). + tokenEncryptionKey: ${HELIOS_TOKEN_ENCRYPTION_KEY:} logging: level: @@ -57,6 +59,7 @@ github: appName: ${GITHUB_APP_NAME:} appId: ${GITHUB_APP_ID:} clientId: ${GITHUB_CLIENT_ID:} + clientSecret: ${GITHUB_CLIENT_SECRET:} installationId: ${GITHUB_INSTALLATION_ID:} privateKeyPath: ${GITHUB_PRIVATE_KEY_PATH:} # GitHub Token exchange credentials diff --git a/server/application-server/src/main/resources/application-prod.yml b/server/application-server/src/main/resources/application-prod.yml index 675137287..5b479868f 100644 --- a/server/application-server/src/main/resources/application-prod.yml +++ b/server/application-server/src/main/resources/application-prod.yml @@ -33,6 +33,8 @@ helios: secretKey: ${HELIOS_STAGING_SECRET_KEY:} clientBaseUrl: "https://helios.aet.cit.tum.de" developers: ${HELIOS_DEVELOPERS_GITHUB_USERNAMES:} + # Base64 AES key for encrypting stored GitHub user tokens at rest (see auth.github.token). + tokenEncryptionKey: ${HELIOS_TOKEN_ENCRYPTION_KEY:} logging: level: @@ -61,6 +63,7 @@ github: appName: ${GITHUB_APP_NAME:} appId: ${GITHUB_APP_ID:} clientId: ${GITHUB_CLIENT_ID:} + clientSecret: ${GITHUB_CLIENT_SECRET:} installationId: ${GITHUB_INSTALLATION_ID:} privateKeyPath: ${GITHUB_PRIVATE_KEY_PATH:} # GitHub Token exchange credentials diff --git a/server/application-server/src/main/resources/application-staging.yml b/server/application-server/src/main/resources/application-staging.yml index abbd44e7f..2d3e164ac 100644 --- a/server/application-server/src/main/resources/application-staging.yml +++ b/server/application-server/src/main/resources/application-staging.yml @@ -33,6 +33,8 @@ helios: secretKey: ${HELIOS_STAGING_SECRET_KEY:} clientBaseUrl: "https://helios-staging.aet.cit.tum.de" developers: ${HELIOS_DEVELOPERS_GITHUB_USERNAMES:} + # Base64 AES key for encrypting stored GitHub user tokens at rest (see auth.github.token). + tokenEncryptionKey: ${HELIOS_TOKEN_ENCRYPTION_KEY:} logging: level: @@ -61,6 +63,7 @@ github: appName: ${GITHUB_APP_NAME:} appId: ${GITHUB_APP_ID:} clientId: ${GITHUB_CLIENT_ID:} + clientSecret: ${GITHUB_CLIENT_SECRET:} installationId: ${GITHUB_INSTALLATION_ID:} privateKeyPath: ${GITHUB_PRIVATE_KEY_PATH:} # GitHub Token exchange credentials diff --git a/server/application-server/src/main/resources/application-test.yml b/server/application-server/src/main/resources/application-test.yml index c3cd27092..9730e4345 100644 --- a/server/application-server/src/main/resources/application-test.yml +++ b/server/application-server/src/main/resources/application-test.yml @@ -41,6 +41,7 @@ github: authToken: "dummy-token" appName: "test-app" clientId: "dummy-client" + clientSecret: "dummy-client-secret" privateKeyPath: "dummy-key.pem" tokenExchangeClientId: "dummy-exchange-client" tokenExchangeClientSecret: "dummy-exchange-secret" @@ -61,3 +62,5 @@ reconciliation: helios: ai: enabled: false + # Dummy base64 AES-256 key so TokenCipher (fail-fast on blank key) can start in tests. + tokenEncryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" diff --git a/server/application-server/src/main/resources/db/migration/V59__create_github_user_token.sql b/server/application-server/src/main/resources/db/migration/V59__create_github_user_token.sql new file mode 100644 index 000000000..5a0dffe4e --- /dev/null +++ b/server/application-server/src/main/resources/db/migration/V59__create_github_user_token.sql @@ -0,0 +1,14 @@ +-- Per-user GitHub token store for Helios-side refresh of Keycloak-brokered tokens. +-- One row per GitHub login. Token columns hold AES-GCM ciphertext (see TokenCipher) — this table +-- never stores plaintext. Rows are seeded once from Keycloak's broker retrieve-token endpoint, then +-- refreshed directly against GitHub so deployment approvals keep working past the 8h GitHub +-- user-token lifetime (Keycloak does not refresh brokered tokens). +CREATE TABLE public.github_user_token ( + id BIGSERIAL PRIMARY KEY, + github_login VARCHAR(255) NOT NULL UNIQUE, + access_token_enc TEXT, + refresh_token_enc TEXT, + access_token_expires_at TIMESTAMPTZ, + refresh_token_expires_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL +); diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubOAuthTokenClientTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubOAuthTokenClientTest.java new file mode 100644 index 000000000..7e4b2ca10 --- /dev/null +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubOAuthTokenClientTest.java @@ -0,0 +1,123 @@ +package de.tum.cit.aet.helios.auth.github.token; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.tum.cit.aet.helios.github.GitHubConfig; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import okhttp3.Call; +import okhttp3.FormBody; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class GitHubOAuthTokenClientTest { + + private final OkHttpClient okHttpClient = mock(OkHttpClient.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final GitHubConfig gitHubConfig = mock(GitHubConfig.class); + private GitHubOAuthTokenClient client; + + @BeforeEach + void setUp() { + when(gitHubConfig.getClientId()).thenReturn("cid"); + when(gitHubConfig.getClientSecret()).thenReturn("secret"); + client = new GitHubOAuthTokenClient(okHttpClient, objectMapper, gitHubConfig); + } + + private static final String URL = "https://github.com/login/oauth/access_token"; + + private void stub(int code, String body) throws IOException { + Response response = + new Response.Builder() + .request(new Request.Builder().url(URL).build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("m") + .body(ResponseBody.create(body, MediaType.parse("application/json"))) + .build(); + Call call = mock(Call.class); + when(okHttpClient.newCall(any(Request.class))).thenReturn(call); + when(call.execute()).thenReturn(response); + } + + @Test + void parsesRefreshedTokensAndExpiries() throws IOException { + stub( + 200, + "{\"access_token\":\"ghu_new\",\"expires_in\":28800,\"refresh_token\":\"ghr_new\"," + + "\"refresh_token_expires_in\":15897600,\"token_type\":\"bearer\"}"); + + GitHubUserTokenRecord record = client.refresh("ghr_old"); + + assertEquals("ghu_new", record.accessToken()); + assertEquals("ghr_new", record.refreshToken()); + assertNotNull(record.accessTokenExpiresAt()); + assertNotNull(record.refreshTokenExpiresAt()); + } + + @Test + void sendsRefreshGrantWithClientCredentials() throws IOException { + stub(200, "{\"access_token\":\"ghu_new\",\"expires_in\":28800}"); + + client.refresh("ghr_old"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + verify(okHttpClient).newCall(captor.capture()); + Request request = captor.getValue(); + assertEquals("application/json", request.header("Accept")); + + FormBody form = (FormBody) request.body(); + Map fields = new HashMap<>(); + for (int i = 0; i < form.size(); i++) { + fields.put(form.name(i), form.value(i)); + } + assertEquals("refresh_token", fields.get("grant_type")); + assertEquals("ghr_old", fields.get("refresh_token")); + assertEquals("cid", fields.get("client_id")); + assertEquals("secret", fields.get("client_secret")); + } + + @Test + void missingRefreshTokenInResponseYieldsNull() throws IOException { + stub(200, "{\"access_token\":\"ghu_new\",\"expires_in\":28800}"); + + GitHubUserTokenRecord record = client.refresh("ghr_old"); + + assertEquals("ghu_new", record.accessToken()); + assertNull(record.refreshToken()); + assertNull(record.refreshTokenExpiresAt()); + } + + @Test + void oauthErrorBodyThrowsReauthRequired() throws IOException { + // GitHub returns HTTP 200 with an error body for a bad refresh token. + stub(200, "{\"error\":\"bad_refresh_token\",\"error_description\":\"expired\"}"); + + assertThrows(GitHubReauthRequiredException.class, () -> client.refresh("ghr_old")); + } + + @Test + void serverErrorIsTransientIoExceptionNotReauth() throws IOException { + stub(500, "upstream boom"); + + IOException e = assertThrows(IOException.class, () -> client.refresh("ghr_old")); + assertFalse(e instanceof GitHubReauthRequiredException); + } +} diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRepositoryIntegrationTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRepositoryIntegrationTest.java new file mode 100644 index 000000000..f7a5a416b --- /dev/null +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenRepositoryIntegrationTest.java @@ -0,0 +1,69 @@ +package de.tum.cit.aet.helios.auth.github.token; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.zonky.test.db.AutoConfigureEmbeddedDatabase; +import java.time.OffsetDateTime; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * Integration test for {@link GitHubUserTokenRepository} against the real Flyway schema (V59) on an + * embedded PostgreSQL (zonky), exercising persistence, lookup by login, the {@code updated_at} + * stamp, and the {@code github_login} unique constraint. + */ +@DataJpaTest(properties = {"spring.flyway.enabled=true", "spring.jpa.hibernate.ddl-auto=none"}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@AutoConfigureEmbeddedDatabase( + type = AutoConfigureEmbeddedDatabase.DatabaseType.POSTGRES, + provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.DOCKER) +@Import(GitHubUserTokenRepositoryIntegrationTest.CacheTestConfig.class) +class GitHubUserTokenRepositoryIntegrationTest { + + /** The {@code @DataJpaTest} slice does not load cache auto-config; supply a no-op manager. */ + @TestConfiguration + static class CacheTestConfig { + @Bean + CacheManager cacheManager() { + return new ConcurrentMapCacheManager(); + } + } + + @Autowired private GitHubUserTokenRepository repository; + + @Test + void savesAndFindsByGithubLoginAndStampsUpdatedAt() { + GitHubUserToken token = new GitHubUserToken(); + token.setGithubLogin("octocat"); + token.setAccessTokenEnc("enc-access"); + token.setRefreshTokenEnc("enc-refresh"); + token.setAccessTokenExpiresAt(OffsetDateTime.now().plusHours(8)); + token.setRefreshTokenExpiresAt(OffsetDateTime.now().plusMonths(6)); + repository.saveAndFlush(token); + + assertThat(repository.findByGithubLogin("octocat")).isPresent(); + assertThat(repository.findByGithubLogin("octocat").get().getUpdatedAt()).isNotNull(); + assertThat(repository.findByGithubLogin("nobody")).isEmpty(); + } + + @Test + void githubLoginIsUnique() { + GitHubUserToken first = new GitHubUserToken(); + first.setGithubLogin("dup"); + repository.saveAndFlush(first); + + GitHubUserToken second = new GitHubUserToken(); + second.setGithubLogin("dup"); + assertThatThrownBy(() -> repository.saveAndFlush(second)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenServiceTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenServiceTest.java new file mode 100644 index 000000000..917008d7f --- /dev/null +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/GitHubUserTokenServiceTest.java @@ -0,0 +1,133 @@ +package de.tum.cit.aet.helios.auth.github.token; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.Base64; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class GitHubUserTokenServiceTest { + + private static final String LOGIN = "octocat"; + private static final String KEY = Base64.getEncoder().encodeToString(new byte[32]); + + private final GitHubUserTokenRepository repository = mock(GitHubUserTokenRepository.class); + private final TokenCipher cipher = new TokenCipher(KEY); + private final KeycloakBrokerTokenClient brokerClient = mock(KeycloakBrokerTokenClient.class); + private final GitHubOAuthTokenClient oauthClient = mock(GitHubOAuthTokenClient.class); + private final GitHubUserTokenService service = + new GitHubUserTokenService(repository, cipher, brokerClient, oauthClient); + + private GitHubUserToken row() { + GitHubUserToken row = new GitHubUserToken(); + row.setGithubLogin(LOGIN); + return row; + } + + @Test + void freshCachedAccessTokenIsReturnedWithoutNetwork() throws IOException { + GitHubUserToken row = row(); + row.setAccessTokenEnc(cipher.encrypt("ghu_cached")); + row.setAccessTokenExpiresAt(OffsetDateTime.now().plusHours(1)); + when(repository.findByGithubLogin(LOGIN)).thenReturn(Optional.of(row)); + + assertEquals("ghu_cached", service.getValidAccessToken(LOGIN)); + verifyNoInteractions(oauthClient, brokerClient); + verify(repository, never()).save(any()); + } + + @Test + void expiredAccessTokenIsRefreshedRotatedAndPersisted() throws IOException { + GitHubUserToken row = row(); + row.setAccessTokenEnc(cipher.encrypt("ghu_old")); + row.setAccessTokenExpiresAt(OffsetDateTime.now().minusHours(1)); + row.setRefreshTokenEnc(cipher.encrypt("ghr_old")); + row.setRefreshTokenExpiresAt(OffsetDateTime.now().plusDays(30)); + when(repository.findByGithubLogin(LOGIN)).thenReturn(Optional.of(row)); + when(oauthClient.refresh("ghr_old")) + .thenReturn( + new GitHubUserTokenRecord( + "ghu_new", + OffsetDateTime.now().plusHours(8), + "ghr_new", + OffsetDateTime.now().plusMonths(6))); + + assertEquals("ghu_new", service.getValidAccessToken(LOGIN)); + verifyNoInteractions(brokerClient); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GitHubUserToken.class); + verify(repository).save(captor.capture()); + assertEquals("ghu_new", cipher.decrypt(captor.getValue().getAccessTokenEnc())); + assertEquals("ghr_new", cipher.decrypt(captor.getValue().getRefreshTokenEnc())); + } + + @Test + void noRowSeedsFromKeycloakThenRefreshes() throws IOException { + when(repository.findByGithubLogin(LOGIN)).thenReturn(Optional.empty()); + when(brokerClient.fetchStoredTokens(LOGIN)) + .thenReturn( + new GitHubUserTokenRecord( + "ghu_seed", OffsetDateTime.now(), "ghr_seed", OffsetDateTime.now().plusMonths(6))); + when(oauthClient.refresh("ghr_seed")) + .thenReturn( + new GitHubUserTokenRecord( + "ghu_new", + OffsetDateTime.now().plusHours(8), + "ghr_new", + OffsetDateTime.now().plusMonths(6))); + + assertEquals("ghu_new", service.getValidAccessToken(LOGIN)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GitHubUserToken.class); + verify(repository).save(captor.capture()); + assertEquals(LOGIN, captor.getValue().getGithubLogin()); + assertEquals("ghr_new", cipher.decrypt(captor.getValue().getRefreshTokenEnc())); + } + + @Test + void expiredRefreshTokenTriggersReseed() throws IOException { + GitHubUserToken row = row(); + row.setAccessTokenEnc(cipher.encrypt("ghu_old")); + row.setAccessTokenExpiresAt(OffsetDateTime.now().minusHours(1)); + row.setRefreshTokenEnc(cipher.encrypt("ghr_expired")); + row.setRefreshTokenExpiresAt(OffsetDateTime.now().minusDays(1)); + when(repository.findByGithubLogin(LOGIN)).thenReturn(Optional.of(row)); + when(brokerClient.fetchStoredTokens(LOGIN)) + .thenReturn( + new GitHubUserTokenRecord( + "ghu_seed", OffsetDateTime.now(), "ghr_seed", OffsetDateTime.now().plusMonths(6))); + when(oauthClient.refresh("ghr_seed")) + .thenReturn( + new GitHubUserTokenRecord( + "ghu_new", OffsetDateTime.now().plusHours(8), "ghr_new", null)); + + assertEquals("ghu_new", service.getValidAccessToken(LOGIN)); + verify(oauthClient, never()).refresh("ghr_expired"); + } + + @Test + void reauthRequiredFromRefreshPropagates() throws IOException { + GitHubUserToken row = row(); + row.setAccessTokenEnc(cipher.encrypt("ghu_old")); + row.setAccessTokenExpiresAt(OffsetDateTime.now().minusHours(1)); + row.setRefreshTokenEnc(cipher.encrypt("ghr_old")); + row.setRefreshTokenExpiresAt(OffsetDateTime.now().plusDays(30)); + when(repository.findByGithubLogin(LOGIN)).thenReturn(Optional.of(row)); + when(oauthClient.refresh(anyString())) + .thenThrow(new GitHubReauthRequiredException("bad_refresh_token")); + + assertThrows( + GitHubReauthRequiredException.class, () -> service.getValidAccessToken(LOGIN)); + } +} diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/KeycloakBrokerTokenClientTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/KeycloakBrokerTokenClientTest.java new file mode 100644 index 000000000..ab6302da1 --- /dev/null +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/KeycloakBrokerTokenClientTest.java @@ -0,0 +1,118 @@ +package de.tum.cit.aet.helios.auth.github.token; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import okhttp3.Call; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class KeycloakBrokerTokenClientTest { + + private static final String ISSUER = "https://kc.example/realms/helios"; + + private final OkHttpClient okHttpClient = mock(OkHttpClient.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + private KeycloakBrokerTokenClient client; + + @BeforeEach + void setUp() { + client = + new KeycloakBrokerTokenClient(okHttpClient, objectMapper, ISSUER, "tec", "tes"); + } + + private static Response response(int code, String body) { + return new Response.Builder() + .request(new Request.Builder().url("https://kc.example/x").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("m") + .body(ResponseBody.create(body, MediaType.parse("application/json"))) + .build(); + } + + /** Stubs the two sequential HTTP calls: token-exchange, then retrieve-token. */ + private void stubExchangeThenRetrieve(Response exchange, Response retrieve) throws IOException { + Call call = mock(Call.class); + when(okHttpClient.newCall(any(Request.class))).thenReturn(call); + when(call.execute()).thenReturn(exchange, retrieve); + } + + @Test + void parsesJsonRetrieveBody() throws IOException { + stubExchangeThenRetrieve( + response(200, "{\"access_token\":\"internal-kc\"}"), + response( + 200, + "{\"access_token\":\"ghu_seed\",\"expires_in\":28800,\"refresh_token\":\"ghr_seed\"," + + "\"refresh_token_expires_in\":15897600}")); + + GitHubUserTokenRecord record = client.fetchStoredTokens("octocat"); + + assertEquals("ghu_seed", record.accessToken()); + assertEquals("ghr_seed", record.refreshToken()); + assertNotNull(record.refreshTokenExpiresAt()); + } + + @Test + void parsesFormEncodedRetrieveBody() throws IOException { + stubExchangeThenRetrieve( + response(200, "{\"access_token\":\"internal-kc\"}"), + response( + 200, + "access_token=ghu_seed&expires_in=28800&refresh_token=ghr_seed" + + "&refresh_token_expires_in=15897600&token_type=bearer")); + + GitHubUserTokenRecord record = client.fetchStoredTokens("octocat"); + + assertEquals("ghu_seed", record.accessToken()); + assertEquals("ghr_seed", record.refreshToken()); + assertNotNull(record.refreshTokenExpiresAt()); + } + + @Test + void noRefreshTokenInStoredBodyThrowsReauthRequired() throws IOException { + stubExchangeThenRetrieve( + response(200, "{\"access_token\":\"internal-kc\"}"), + response(200, "{\"access_token\":\"ghu_seed\",\"expires_in\":28800}")); + + assertThrows( + GitHubReauthRequiredException.class, () -> client.fetchStoredTokens("octocat")); + } + + @Test + void tokenExchangeFailureThrowsIoException() throws IOException { + Call call = mock(Call.class); + when(okHttpClient.newCall(any(Request.class))).thenReturn(call); + when(call.execute()).thenReturn(response(401, "{\"error\":\"unauthorized_client\"}")); + + IOException e = + assertThrows(IOException.class, () -> client.fetchStoredTokens("octocat")); + // Not a reauth signal — this is a Keycloak/config failure, treated as transient. + org.junit.jupiter.api.Assertions.assertFalse(e instanceof GitHubReauthRequiredException); + } + + @Test + void retrieveTokenForbiddenThrowsIoException() throws IOException { + stubExchangeThenRetrieve( + response(200, "{\"access_token\":\"internal-kc\"}"), + response( + 403, + "{\"errorMessage\":\"Client [helios-token-exchange] not authorized to retrieve " + + "tokens from identity provider [github].\"}")); + + assertThrows(IOException.class, () -> client.fetchStoredTokens("octocat")); + } +} diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/TokenCipherTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/TokenCipherTest.java new file mode 100644 index 000000000..5d8624d10 --- /dev/null +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/auth/github/token/TokenCipherTest.java @@ -0,0 +1,36 @@ +package de.tum.cit.aet.helios.auth.github.token; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class TokenCipherTest { + + // A valid 256-bit AES key (all-zero bytes is fine for a round-trip test). + private static final String KEY = Base64.getEncoder().encodeToString(new byte[32]); + + @Test + void encryptThenDecryptRoundTrips() { + TokenCipher cipher = new TokenCipher(KEY); + String encrypted = cipher.encrypt("ghr_secret_refresh_token"); + assertNotEquals("ghr_secret_refresh_token", encrypted); + assertEquals("ghr_secret_refresh_token", cipher.decrypt(encrypted)); + } + + @Test + void encryptUsesRandomIvSoCiphertextsDiffer() { + TokenCipher cipher = new TokenCipher(KEY); + assertNotEquals(cipher.encrypt("same-plaintext"), cipher.encrypt("same-plaintext")); + } + + @Test + void blankKeyDoesNotFailConstructionButFailsOnUse() { + // A missing key must not crash app startup; it fails only when a token op is attempted. + TokenCipher cipher = new TokenCipher(" "); + assertThrows(IllegalStateException.class, () -> cipher.encrypt("x")); + assertThrows(IllegalStateException.class, () -> cipher.decrypt("aaa:bbb")); + } +} diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/github/GitHubServiceTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/github/GitHubServiceTest.java index cd41bd325..991d91f39 100644 --- a/server/application-server/src/test/java/de/tum/cit/aet/helios/github/GitHubServiceTest.java +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/github/GitHubServiceTest.java @@ -23,8 +23,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.cit.aet.helios.auth.AuthService; -import de.tum.cit.aet.helios.auth.github.GitHubAuthBroker; -import de.tum.cit.aet.helios.auth.github.TokenExchangeResponse; +import de.tum.cit.aet.helios.auth.github.token.GitHubUserTokenService; import de.tum.cit.aet.helios.deployment.github.GitHubDeploymentDto; import de.tum.cit.aet.helios.environment.github.GitHubEnvironmentApiResponse; import de.tum.cit.aet.helios.environment.github.GitHubEnvironmentDto; @@ -88,7 +87,7 @@ class GitHubServiceTest { @Mock private AuthService authService; - @Mock private GitHubAuthBroker gitHubAuthBroker; + @Mock private GitHubUserTokenService gitHubUserTokenService; @Mock private GitHubClientManager clientManager; @@ -105,7 +104,7 @@ void setUp() { objectMapper, okHttpClient, authService, - gitHubAuthBroker, + gitHubUserTokenService, clientManager); ReflectionTestUtils.setField(gitHubService, "heliosClientBaseUrl", "http://localhost:4200"); repositoryContextMockedStatic = mockStatic(RepositoryContext.class); @@ -899,9 +898,7 @@ void approveDeploymentOnBehalfOfUserSuccess() throws IOException { "{\"environment_ids\":[10],\"state\":\"approved\",\"comment\":\"Automatically approved by" + " Helios\"}"; - TokenExchangeResponse tokenResponse = new TokenExchangeResponse(); - tokenResponse.setAccessToken(userGithubToken); - when(gitHubAuthBroker.exchangeToken(githubUserLogin)).thenReturn(tokenResponse); + when(gitHubUserTokenService.getValidAccessToken(githubUserLogin)).thenReturn(userGithubToken); when(objectMapper.writeValueAsString(any())).thenReturn(jsonPayload); Response mockResponse = @@ -921,7 +918,7 @@ void approveDeploymentOnBehalfOfUserSuccess() throws IOException { gitHubService.approveDeploymentOnBehalfOfUser( repoNameWithOwner, runId, environmentId, githubUserLogin)); - verify(gitHubAuthBroker).exchangeToken(githubUserLogin); + verify(gitHubUserTokenService).getValidAccessToken(githubUserLogin); verify(objectMapper) .writeValueAsString( Map.of( @@ -932,27 +929,26 @@ void approveDeploymentOnBehalfOfUserSuccess() throws IOException { } @Test - void approveDeploymentOnBehalfOfUserTokenExchangeNull() throws IOException { + void approveDeploymentOnBehalfOfUserPropagatesTokenFailure() throws IOException { String repoNameWithOwner = "owner/repo"; long runId = 1L; Long environmentId = 10L; String githubUserLogin = "testUser"; - when(gitHubAuthBroker.exchangeToken(githubUserLogin)).thenReturn(null); - // objectMapper.writeValueAsString runs before the token check. + // objectMapper.writeValueAsString runs before the token is fetched. when(objectMapper.writeValueAsString(anyMap())).thenReturn("{}"); + // A token that can't be obtained/refreshed surfaces as IOException so callers can mark the + // approval FAILED_AT_GITHUB and react, instead of a silent no-op. + when(gitHubUserTokenService.getValidAccessToken(githubUserLogin)) + .thenThrow(new IOException("no valid GitHub token for user")); - // Token-exchange failure now surfaces as an IOException so callers can mark the approval - // FAILED_AT_GITHUB and react, instead of the legacy silent return that left deployments - // stuck in WAITING with no signal. IOException exception = assertThrows( IOException.class, () -> gitHubService.approveDeploymentOnBehalfOfUser( repoNameWithOwner, runId, environmentId, githubUserLogin)); - assertTrue(exception.getMessage().contains("Failed to exchange GitHub token")); - verify(objectMapper, times(1)).writeValueAsString(anyMap()); + assertTrue(exception.getMessage().contains("no valid GitHub token")); verify(okHttpClient, never()).newCall(any(Request.class)); } @@ -967,9 +963,7 @@ void approveDeploymentOnBehalfOfUserApiFailure() throws IOException { "{\"environment_ids\":[10],\"state\":\"approved\",\"comment\":\"Automatically approved by" + " Helios\"}"; - TokenExchangeResponse tokenResponse = new TokenExchangeResponse(); - tokenResponse.setAccessToken(userGithubToken); - when(gitHubAuthBroker.exchangeToken(githubUserLogin)).thenReturn(tokenResponse); + when(gitHubUserTokenService.getValidAccessToken(githubUserLogin)).thenReturn(userGithubToken); when(objectMapper.writeValueAsString(any())).thenReturn(jsonPayload); ResponseBody responseBody = @@ -1005,9 +999,7 @@ void approveDeploymentOnBehalfOfUserPassesCommentToGitHub() throws IOException { final String userGithubToken = "user-token"; final String customComment = "Approved by @alice via Helios (in-app)"; - TokenExchangeResponse tokenResponse = new TokenExchangeResponse(); - tokenResponse.setAccessToken(userGithubToken); - when(gitHubAuthBroker.exchangeToken(githubUserLogin)).thenReturn(tokenResponse); + when(gitHubUserTokenService.getValidAccessToken(githubUserLogin)).thenReturn(userGithubToken); when(objectMapper.writeValueAsString(any())).thenReturn("{}"); Response mockResponse = @@ -1043,9 +1035,7 @@ void rejectDeploymentOnBehalfOfUserSendsRejectedState() throws IOException { final String userGithubToken = "user-token"; final String comment = "Declined by @alice via Helios (in-app)"; - TokenExchangeResponse tokenResponse = new TokenExchangeResponse(); - tokenResponse.setAccessToken(userGithubToken); - when(gitHubAuthBroker.exchangeToken(githubUserLogin)).thenReturn(tokenResponse); + when(gitHubUserTokenService.getValidAccessToken(githubUserLogin)).thenReturn(userGithubToken); when(objectMapper.writeValueAsString(any())).thenReturn("{}"); Response mockResponse = @@ -1191,9 +1181,7 @@ void createReleaseOnBehalfOfUserSuccess() throws IOException { final String userGithubToken = "user-token"; final long releaseId = 12345L; - TokenExchangeResponse tokenResponse = new TokenExchangeResponse(); - tokenResponse.setAccessToken(userGithubToken); - when(gitHubAuthBroker.exchangeToken(githubUserLogin)).thenReturn(tokenResponse); + when(gitHubUserTokenService.getValidAccessToken(githubUserLogin)).thenReturn(userGithubToken); Map expectedPayload = Map.of( @@ -1233,7 +1221,7 @@ void createReleaseOnBehalfOfUserSuccess() throws IOException { repoNameWithOwner, tagName, commitish, name, body, draft, githubUserLogin); assertEquals(mockRelease, actualRelease); - verify(gitHubAuthBroker).exchangeToken(githubUserLogin); + verify(gitHubUserTokenService).getValidAccessToken(githubUserLogin); verify(objectMapper).writeValueAsString(expectedPayload); verify(okHttpClient).newCall(any(Request.class)); verify(objectMapper).readValue(responseJson, Map.class); @@ -1241,10 +1229,11 @@ void createReleaseOnBehalfOfUserSuccess() throws IOException { } @Test - void createReleaseOnBehalfOfUserTokenExchangeFails() throws IOException { + void createReleaseOnBehalfOfUserTokenFailurePropagates() throws IOException { String repoNameWithOwner = "owner/repo"; String githubUserLogin = "testUser"; - when(gitHubAuthBroker.exchangeToken(githubUserLogin)).thenReturn(null); + when(gitHubUserTokenService.getValidAccessToken(githubUserLogin)) + .thenThrow(new IOException("no valid GitHub token for user")); IOException exception = assertThrows( @@ -1253,8 +1242,7 @@ void createReleaseOnBehalfOfUserTokenExchangeFails() throws IOException { gitHubService.createReleaseOnBehalfOfUser( repoNameWithOwner, "v1", "main", "name", "body", false, githubUserLogin); }); - assertEquals( - "Failed to exchange token for GitHub user: " + githubUserLogin, exception.getMessage()); + assertTrue(exception.getMessage().contains("no valid GitHub token")); } @Test @@ -1263,9 +1251,7 @@ void createReleaseOnBehalfOfUserApiFailure() throws IOException { final String githubUserLogin = "testUser"; final String userGithubToken = "user-token"; - TokenExchangeResponse tokenResponse = new TokenExchangeResponse(); - tokenResponse.setAccessToken(userGithubToken); - when(gitHubAuthBroker.exchangeToken(githubUserLogin)).thenReturn(tokenResponse); + when(gitHubUserTokenService.getValidAccessToken(githubUserLogin)).thenReturn(userGithubToken); when(objectMapper.writeValueAsString(anyMap())).thenReturn("{}"); // Dummy JSON ResponseBody errorResponseBody =