Skip to content

feat(auth): refresh the session's GitHub token near/past its 8h expiry - #6129

Merged
JSONbored merged 2 commits into
mainfrom
feat/github-token-refresh
Jul 15, 2026
Merged

feat(auth): refresh the session's GitHub token near/past its 8h expiry#6129
JSONbored merged 2 commits into
mainfrom
feat/github-token-refresh

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Closes #6115

Decision (this issue's own first deliverable)

Refresh-token rotation, not disabling token expiration on the Loopover ORB App. Confirmed via GitHub's own docs before committing to this:

  • GitHub App user-to-server tokens expire 8h after issue by default, with a 6-month refresh_token issued alongside — this is only omitted if the App owner explicitly opts OUT of token expiration in the App's own settings.
  • The refresh exchange (POST https://github.com/login/oauth/access_token, grant_type=refresh_token) is single-use-then-rotated: using a refresh token invalidates it and the prior access token, and issues a fresh 6-month refresh token in the response.
  • Given this App already has Contents/Pull requests/Issues/Checks/Workflows at Read & write, a non-expiring token is a materially bigger blast radius on any future leak (log line, DB dump, debug endpoint) for zero real implementation-effort savings over honoring GitHub's own default rotation contract — the issue's own framing of this tradeoff. Not a close call given how explicit that framing is.

Summary

  • getLiveSessionGitHubToken (src/auth/github-oauth.ts) is the new resolver: returns the stored access token as-is when it's not within 15 minutes of expiry, or when there's no known expiry at all (a Persist + expose the GitHub user-to-server token from login instead of discarding it #6114-era row, or an exchange whose response never included expires_in — treated as never-expiring for backward compatibility). Otherwise it transparently refreshes via the stored refresh_token and persists the rotated pair.
  • Handles GitHub's single-use-then-rotate contract: a failed refresh re-checks the stored row once before giving up, in case a concurrent request already consumed and rotated the same refresh token — recovering with that request's result instead of failing outright.
  • POST /v1/auth/github/token now calls this instead of the bare decrypt, so AMS gets a live token transparently across an arbitrarily long-running session without ever needing a fresh login.
  • auth_session_github_tokens (migration 0155) gains expires_at plus a second full encrypted-envelope column set (refresh_ciphertext/refresh_iv/refresh_salt/refresh_key_version) and refresh_expires_at — all nullable, since a Persist + expose the GitHub user-to-server token from login instead of discarding it #6114-era row or the caller-supplied-token exchange path (/v1/auth/github/session) has no lifecycle info to offer.
  • pollGitHubDeviceFlow/completeGitHubWebOAuth now capture expires_in/refresh_token/refresh_token_expires_in from GitHub's response (when present) and thread them through createSessionFromGitHubTokencreateSessionForGitHubUserstoreSessionGitHubToken.

Test plan

  • 15 new tests in test/unit/auth-github-token.test.ts: end-to-end capture of the lifecycle fields through the device-flow login; fast-path (no refresh call when fresh); refresh-triggers-and-persists-the-rotated-pair; falls back to a stale token when there's nothing to refresh with; refuses to even attempt a refresh when the refresh token is itself expired; treats an absent expiry as never-expiring; the concurrent-refresh race recovery (and its failure-with-no-concurrent-update sibling); a malformed/non-JSON refresh response degrades to failure, not a crash; GITHUB_OAUTH_CLIENT_ID/SECRET unconfigured; getDecryptedSessionGitHubTokenBundle's own no-key / access-decrypt-failure / refresh-decrypt-failure-degrades-independently paths.
  • Caught and fixed two real test-design bugs myself while chasing scoped coverage: my original "recovers from concurrent refresh" test stored the concurrent update before calling the function under test, so it only ever exercised the fast path, never the retry — fixed by moving the write into the mocked fetch's own side effect (the only way to genuinely land it between the initial read and the retry read in a single-threaded test).
  • Precisely verified 100% statement/branch coverage on every new/changed line across github-oauth.ts, security.ts, and repositories.ts via the raw v8 coverage JSON (cross-referenced against the exact diff hunks, not just the aggregate file-level percentage, which understates coverage for these large files when running a narrow test subset).
  • npm run db:migrations:check / db:schema-drift:check pass.
  • Full local npm run test:ci gate green (before and after rebasing onto main post-Persist + expose the GitHub user-to-server token from login instead of discarding it #6114-merge).

Closes #6115

Decision: refresh-token rotation, not disabling token expiration on the
Loopover ORB App. GitHub Apps issue an 8h access token + a 6-month
refresh_token by default (only omitted if the App owner explicitly opts
out); given this App already has Contents/Pull requests/Issues/Checks/
Workflows at Read & write, a non-expiring token widens the blast radius of
any future leak for no real implementation-effort savings over honoring
GitHub's own default rotation contract. Confirmed via GitHub's docs
(exact response shape, 8h/6mo defaults, and that the refresh_token is
single-use-then-rotated on every refresh) before committing to this.

getLiveSessionGitHubToken (src/auth/github-oauth.ts) resolves a currently-
live token: returns the stored access token as-is when it's not near expiry
or has no known expiry (a #6114-era row, or an exchange that never
returned expires_in -- treated as never-expiring for backward compat),
otherwise transparently refreshes via the stored refresh_token and
persists the rotated pair. A failed refresh re-checks once before giving
up, in case a concurrent request already consumed and rotated the same
refresh token (GitHub invalidates both the old access AND refresh token on
first use of either). POST /v1/auth/github/token now calls this instead of
the bare decrypt, so AMS transparently gets a live token across an
arbitrarily long-running session without ever needing a fresh login.

auth_session_github_tokens gains expires_at + a second encrypted
ciphertext/iv/salt/key_version set for the refresh token, plus its own
expiry -- all nullable, since a #6114-era row or a caller-supplied-token
exchange has no lifecycle info to offer.
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@JSONbored JSONbored self-assigned this Jul 15, 2026
@JSONbored
JSONbored merged commit f825c9c into main Jul 15, 2026
15 checks passed
@JSONbored
JSONbored deleted the feat/github-token-refresh branch July 15, 2026 11:34
@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 15, 2026
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.32%. Comparing base (ddc1257) to head (3db50f3).
⚠️ Report is 6 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #6129   +/-   ##
=======================================
  Coverage   95.31%   95.32%           
=======================================
  Files         596      596           
  Lines       47154    47204   +50     
  Branches    15047    15069   +22     
=======================================
+ Hits        44946    44996   +50     
  Misses       1477     1477           
  Partials      731      731           
Flag Coverage Δ
shard-1 43.94% <3.44%> (-0.05%) ⬇️
shard-2 36.64% <24.13%> (-0.02%) ⬇️
shard-3 32.20% <20.68%> (-0.02%) ⬇️
shard-4 33.92% <3.44%> (-0.04%) ⬇️
shard-5 31.61% <3.44%> (-0.04%) ⬇️
shard-6 44.76% <100.00%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/api/routes.ts 94.42% <100.00%> (ø)
src/auth/github-oauth.ts 96.06% <100.00%> (+1.01%) ⬆️
src/auth/security.ts 98.88% <100.00%> (+0.01%) ⬆️
src/db/repositories.ts 96.80% <100.00%> (+0.03%) ⬆️
src/db/schema.ts 72.97% <ø> (ø)

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

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide + implement GitHub token refresh/expiration handling for long-running AMS sessions

1 participant