Skip to content

fix: use ghinstallation for GitHub App token refresh#116

Open
abebars wants to merge 3 commits intoConductorOne:mainfrom
abebars:fix/github-app-token-refresh
Open

fix: use ghinstallation for GitHub App token refresh#116
abebars wants to merge 3 commits intoConductorOne:mainfrom
abebars:fix/github-app-token-refresh

Conversation

@abebars
Copy link
Contributor

@abebars abebars commented Jan 30, 2026

Note

🤖 Generated with Claude Code

Fixes #115

Summary

Replace manual JWT handling with ghinstallation/v2 library to fix token expiration issues on long-running syncs. Also fixes Enterprise SAML handling and GitHub Enterprise URL configuration.

Problem

When using GitHub App authentication, the connector fails with 401 Unauthorized errors after ~10 minutes because:

  1. JWT tokens (10-min max expiry) were generated once at startup
  2. Stored statically in appTokenRefresher.jwttoken
  3. When installation tokens expired (~1 hour), the expired JWT was reused

Additional issues found in production:
4. REST clients not configured with Enterprise URLs for GitHub Enterprise instances
5. Token expiry not set, causing oauth2 to cache tokens indefinitely
6. Enterprise SAML error crashes sync when org-level SAML is disabled in favor of Enterprise SAML

Solution

Use github.com/bradleyfalzon/ghinstallation/v2 - the officially recommended library by go-github that:

  • Automatically regenerates JWTs before they expire
  • Automatically refreshes installation tokens
  • Is thread-safe and battle-tested
  • Supports GitHub Enterprise

Plus additional fixes:

  • Configure WithEnterpriseURLs() on REST clients for GitHub Enterprise
  • Set token expiry from ghinstallation transport for proper oauth2 refresh
  • Handle Enterprise SAML error in hasSAML() gracefully (return false instead of failing)

Changes

  • Add ghinstallation/v2 dependency
  • Replace appTokenRefresher with ghinstallationTokenSource wrapper
  • Configure Enterprise URLs on appClient and ghClient (addresses CodeRabbit feedback)
  • Set token Expiry from transport for oauth2 refresh (addresses CodeRabbit feedback)
  • Handle Enterprise SAML error in hasSAML() to prevent sync failures
  • Add tests for token refresh behavior including expired token scenarios
  • Remove dead code (getClientToken, loadPrivateKeyFromString, appTokenRefresher)

Testing

  • Unit tests pass
  • Token refresh test verifies expired tokens are renewed
  • PAT authentication unchanged and tested
  • Build succeeds
  • Deployed to production - successfully syncing 6,114 repos, 744 teams, 1,561 users

Production Validation

This fix has been deployed and validated in production:

  • Running on EKS with GitHub App authentication
  • Successfully syncing large GitHub organization (6K+ repos)
  • Enterprise SAML handling confirmed working
  • JWT refresh verified over multiple hours

Rollback Plan

Revert PR - PAT authentication is unchanged, only GitHub App auth is affected.

Summary by CodeRabbit

  • New Features

    • GitHub App authentication now uses installation-based tokens with automatic refresh and enterprise URL support; PAT authentication remains supported.
  • Tests

    • Added comprehensive tests covering App and PAT authentication flows, token caching/expiry, refresh behavior, and misconfiguration cases.
  • Chores

    • Updated indirect dependencies to support the enhanced authentication implementations.

@abebars abebars requested a review from a team January 30, 2026 04:03
@coderabbitai
Copy link

coderabbitai bot commented Jan 30, 2026

Walkthrough

Replaces manual GitHub App JWT handling with ghinstallation-based App/Installation transports and an oauth2 TokenSource; adds indirect dependencies and tests for App and PAT authentication, token refresh/expiry, and multi-org misconfiguration.

Changes

Cohort / File(s) Summary
Dependency Management
go.mod
Added indirect dependency github.com/bradleyfalzon/ghinstallation/v2; updated JWT and go-github versions (github.com/golang-jwt/jwt/v4, github.com/google/go-github/v75) and removed jwt/v5.
Authentication Implementation
pkg/connector/connector.go
Replaced custom JWT generation and manual installation-token refresh with ghinstallation App+Installation transports; introduced ghinstallationTokenSource as oauth2.TokenSource; added enterprise base-URL handling and changed GraphQL client to use token source; removed legacy JWT helpers and static JWT reuse.
Authentication Tests
pkg/connector/connector_test.go
Added tests using httptest, in-memory RSA keys, and ghinstallation v2 covering ghinstallation token sourcing, caching/auto-refresh, expiry handling, PAT flow, and multi-org App misconfiguration.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant GHT as ghinstallation Transport
    participant TS as ghinstallationTokenSource
    participant GH as GitHub API
    participant GQL as GraphQL Client

    App->>GHT: Initialize App transport (AppID, PrivateKey[, EnterpriseURL])
    App->>TS: Wrap GHT as oauth2.TokenSource
    App->>GQL: Create GraphQL client using TS

    GQL->>TS: Request token
    TS->>GHT: Request installation token
    GHT->>GH: Use JWT to request installation token
    GH-->>GHT: Return installation token (with expiry)
    GHT-->>TS: Provide oauth2.Token
    TS->>TS: Cache token until expiry
    TS-->>GQL: Return oauth2.Token

    note over GHT,TS: On expiry, ghinstallation regenerates JWT and refreshes installation token automatically
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nibble keys beneath the moonlit logs,

Tokens sprout anew from reliable clogs,
ghinstallation hums a steady tune,
No stale JWTs to whistle at noon,
Syncs hop on, and I wink at the moon.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: use ghinstallation for GitHub App token refresh' directly summarizes the main change: replacing manual JWT handling with the ghinstallation library for automatic token refresh.
Linked Issues check ✅ Passed The PR fully addresses issue #115 by implementing ghinstallation/v2 dependency, replacing appTokenRefresher with ghinstallationTokenSource, configuring enterprise URLs, and adding token refresh tests.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing GitHub App token refresh: dependency updates, authentication flow refactoring, token source implementation, test coverage, and removal of obsolete JWT/token-refresh code.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

@abebars abebars force-pushed the fix/github-app-token-refresh branch from 7248766 to 2d4bfae Compare January 30, 2026 04:08
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@pkg/connector/connector.go`:
- Around line 267-309: The REST clients appClient and ghClient are not
configured for GitHub Enterprise because only appTransport.BaseURL and
installTransport.BaseURL are set; call client.WithEnterpriseURLs(instanceURL) on
both github.Client instances (appClient and ghClient) when instanceURL is
non-empty and not equal to githubDotCom so their BaseURL fields point at the
enterprise API; locate where appClient is created after
ghinstallation.NewAppsTransport and where ghClient is created after
ghinstallation.New and invoke WithEnterpriseURLs using the same instanceURL
logic as used for appTransport/installTransport, matching the pattern used by
newGitHubClient for PAT auth.
- Around line 394-406: The Token() implementation on ghinstallationTokenSource
returns an oauth2.Token with no Expiry so oauth2.ReuseTokenSource treats it as
non-expiring; update Token() to set the token Expiry (e.g., Expiry: time.Now())
so the oauth2 client will re-evaluate and allow ghinstallation.Transport to
refresh tokens. Modify ghinstallationTokenSource.Token to import time, call
g.transport.Token(context.Background()), and return &oauth2.Token{AccessToken:
token, Expiry: time.Now()} (ensuring use of the ghinstallationTokenSource,
Token(), ghinstallation.Transport and oauth2.Token symbols referenced).
🧹 Nitpick comments (1)
pkg/connector/connector_test.go (1)

90-95: Optional: track the skipped GitHub App test.
Consider linking this to an issue or moving it under an integration test tag so it doesn’t stay skipped indefinitely.

@abebars abebars force-pushed the fix/github-app-token-refresh branch from ac67be8 to 1b71e4b Compare January 30, 2026 15:49
@dslick
Copy link

dslick commented Jan 30, 2026

We are actively reviewing this and investigating alternative solutions.

@abebars
Copy link
Contributor Author

abebars commented Feb 2, 2026

We are actively reviewing this and investigating alternative solutions.

Thanks @dslick. What are the risks of switching this functionality to a tested framework instead of crafting it manually?

@abebars
Copy link
Contributor Author

abebars commented Feb 5, 2026

I tested the new changes and they still didn't solve the problem.

abebars and others added 3 commits February 5, 2026 11:36
Replace manual JWT handling with ghinstallation/v2 library to fix
token expiration issues on long-running syncs.

Problem: JWT tokens (10-min expiry) were generated once at startup
and reused, causing 401 errors when installation tokens expired.

Solution: Use ghinstallation which automatically regenerates JWTs
and refreshes installation tokens as needed.

Changes:
- Add ghinstallation/v2 dependency
- Replace appTokenRefresher with ghinstallationTokenSource
- Add tests for token refresh behavior
- Remove dead code (getClientToken, loadPrivateKeyFromString)

Fixes ConductorOne#115

Co-Authored-By: Claude <noreply@anthropic.com>
- Handle Enterprise SAML error in hasSAML() to prevent sync failures
  when org-level SAML is disabled in favor of Enterprise SAML
- Configure Enterprise URLs on appClient and ghClient for GitHub
  Enterprise instances (addresses CodeRabbit feedback)
- Set token Expiry from ghinstallation transport for proper oauth2
  token refresh behavior (addresses CodeRabbit feedback)

Co-Authored-By: Claude <noreply@anthropic.com>
- Fix errcheck: handle w.Write return values in tests
- Fix goconst: extract repeated test URL path to constant
- Fix gocritic ifElseChain: add nolint directive (switch inappropriate)
- Fix nilerr: rename variable and add nolint for intentional fallback
- Fix unused: remove unused getInstallationToken function
- Fix gosec G101: add nolint for test constant (not credentials)
- Remove unused io import

Co-Authored-By: Claude <noreply@anthropic.com>
@abebars abebars force-pushed the fix/github-app-token-refresh branch from 41cc574 to be10b21 Compare February 5, 2026 17:05
@Bencheng21
Copy link
Contributor

Bencheng21 commented Feb 5, 2026

I tested the new changes and they still didn't solve the problem.

Hey @abebars could you please tell me

  1. what version of the connector you are using?
  2. do you use it in service-mode or in c1?
  3. is the error still 401?

@abebars
Copy link
Contributor Author

abebars commented Feb 5, 2026

I tested the new changes and they still didn't solve the problem.

Hey @abebars could you please tell me

  1. what version of the connector you are using?
  2. do you use it in service-mode or in c1?
  3. is the error still 401?
    @Bencheng21
  1. Using ghcr.io/conductorone/baton-github:0.2.4-test.1
  2. Running in K8s Containers
  3. Yeah, after 1 hou,r the token expires
image image

@Bencheng21
Copy link
Contributor

Hello @abebars , please try with https://github.com/ConductorOne/baton-github/releases/tag/v0.2.7,

  1. auth-method is required, for example. ./baton-github --auth-method github-app-group
  2. we should use org instead of orgs when using app authentication flow.

@abebars
Copy link
Contributor Author

abebars commented Feb 6, 2026

Hello @abebars , please try with https://github.com/ConductorOne/baton-github/releases/tag/v0.2.7,

  1. auth-method is required, for example. ./baton-github --auth-method github-app-group
  2. we should use org instead of orgs when using app authentication flow.

@Bencheng21 Yeah, Just deployed that. I will report back shortly. Thank you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitHub App JWT token expires after 10 minutes, causing 401 on subsequent syncs

3 participants