Skip to content

feat(lease): add GitHub App installation token lease backend#342

Merged
jdx merged 8 commits intomainfrom
feat/github-app-lease
Mar 9, 2026
Merged

feat(lease): add GitHub App installation token lease backend#342
jdx merged 8 commits intomainfrom
feat/github-app-lease

Conversation

@jdx
Copy link
Copy Markdown
Owner

@jdx jdx commented Mar 9, 2026

Summary

  • Adds a new github-app lease backend that creates short-lived GitHub installation access tokens from a GitHub App's private key
  • Supports scoping tokens to specific permissions and repositories
  • Configurable env_var (default: GITHUB_TOKEN), api_base for GitHub Enterprise
  • Tokens auto-expire in ≤1 hour (GitHub's hard limit), cached via existing lease ledger

Example config

[leases.github]
type = "github-app"
app_id = "12345"
installation_id = "67890"
private_key_file = "~/.config/fnox/github-app.pem"

[leases.github.permissions]
contents = "read"
pull_requests = "write"

repositories = ["my-org/my-repo"]

Test plan

  • 9 bats tests covering: token creation (file + env var), fnox exec integration, custom env_var, permissions/repositories config, missing key error, lease list
  • All tests use a mock HTTP server (no real GitHub credentials needed)
  • Existing cargo + bats tests pass

🤖 Generated with Claude Code


Note

Medium Risk
Adds new networked auth/crypto code for generating and revoking GitHub tokens and changes the LeaseBackend::revoke_lease API across all backends, so regressions could affect lease cleanup/revocation behavior.

Overview
Adds a new github-app lease backend that creates GitHub App installation access tokens by signing a short-lived JWT with an RSA private key (from FNOX_GITHUB_APP_PRIVATE_KEY or private_key_file), supports optional permission/repository scoping and custom api_base, and generates non-secret lease_ids derived from a token hash.

Updates lease revocation to pass decrypted cached credentials into LeaseBackend::revoke_lease (trait signature change applied across existing backends) so backends can revoke using the actual credential value; lease cleanup now explicitly skips passing creds for expired leases. Also sets a default User-Agent on the shared reqwest client.

Extends config schema and docs to document the new backend, adds bats tests with a mock GitHub API server, and updates dependencies (jsonwebtoken v10 with aws_lc_rs, plus lockfile changes around untrusted).

Written by Cursor Bugbot for commit eed87bd. This will update automatically on new commits. Configure here.

Adds a new `github-app` lease backend that creates short-lived GitHub
installation access tokens from a GitHub App's private key. Supports
scoping tokens to specific permissions and repositories, custom env var
names, and GitHub Enterprise via configurable api_base.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly expands the fnox credential management capabilities by introducing a dedicated lease backend for GitHub App installation tokens. This feature allows users to securely obtain and manage temporary GitHub access tokens, which are automatically scoped and expired, thereby improving the security posture for automated workflows and integrations with GitHub.

Highlights

  • New GitHub App Lease Backend: Introduced a new github-app lease backend that generates short-lived GitHub installation access tokens, enhancing security for GitHub integrations.
  • Token Scoping and Configuration: The new backend supports scoping tokens to specific permissions and repositories, and allows configuration of the environment variable name (defaulting to GITHUB_TOKEN) and GitHub Enterprise API base URL.
  • Automatic Token Expiration and Caching: GitHub App tokens automatically expire within GitHub's 1-hour hard limit and are efficiently cached using the existing lease ledger mechanism.
  • Comprehensive Testing: Added 9 Bats tests covering token creation (file and environment variable), fnox exec integration, custom environment variables, permissions/repositories configuration, and error handling, all utilizing a mock HTTP server.
Changelog
  • Cargo.lock
    • Updated dependency versions for jsonwebtoken and untrusted.
    • Added jsonwebtoken version 10.3.0 as a new package dependency.
  • Cargo.toml
    • Added jsonwebtoken dependency with aws_lc_rs feature enabled.
  • src/lease_backends/github_app.rs
    • Added a new module implementing the GitHubAppBackend for creating and managing GitHub App installation tokens.
    • Implemented logic for loading private keys from file or environment variables, generating JWTs, and making API calls to GitHub.
  • src/lease_backends/mod.rs
    • Imported the new github_app module.
    • Added a GithubApp variant to the LeaseBackendConfig enum to support the new backend configuration.
    • Integrated github_app prerequisites and environment variable requirements into the LeaseBackendConfig implementation.
    • Included the GithubApp backend in the build_backend and get_duration methods.
  • test/lease_github_app.bats
    • Added a new Bats test suite to validate the functionality of the GitHub App lease backend.
    • Included tests for token creation via file and environment variable, fnox exec integration, custom environment variables, lease ledger recording, error handling for missing keys, and support for permissions and repositories configuration.
Activity
  • The author has implemented a new github-app lease backend.
  • The author has provided an example configuration for the new backend.
  • The author has outlined a comprehensive test plan, including 9 Bats tests using a mock HTTP server, and confirmed all existing tests pass.
  • The pull request was generated with Claude Code.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Comment thread test/lease_github_app.bats Outdated
Comment thread src/lease_backends/github_app.rs
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a new github-app lease backend, which is a valuable addition. While the implementation is well-structured and includes comprehensive tests, a critical security vulnerability has been identified: the revoke_lease method is currently a no-op. This means that tokens are not invalidated on GitHub's side when a lease is revoked, leaving a window of risk for compromised tokens until they naturally expire (up to 1 hour). It is strongly recommended to implement proper revocation logic using GitHub's API. Furthermore, potential panics due to unchecked unwrap() calls should be addressed for robustness, and there's an opportunity to improve performance and code design by reducing cloning in the backend creation logic.

Comment on lines +139 to +150
if let Some(ref permissions) = self.permissions {
body.insert(
"permissions".to_string(),
serde_json::to_value(permissions).unwrap(),
);
}
if let Some(ref repositories) = self.repositories {
body.insert(
"repositories".to_string(),
serde_json::to_value(repositories).unwrap(),
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The use of .unwrap() on the result of serde_json::to_value could lead to a panic if serialization fails for any reason. While it's unlikely for these types, it's safer to handle the Result gracefully to prevent the application from crashing.

        if let Some(ref permissions) = self.permissions {
            body.insert(
                "permissions".to_string(),
                serde_json::to_value(permissions).map_err(|e| FnoxError::ProviderApiError {
                    provider: "GitHub App".to_string(),
                    details: format!("Failed to serialize permissions: {e}"),
                    hint: "This is likely an internal bug.".to_string(),
                    url: URL.to_string(),
                })?,
            );
        }
        if let Some(ref repositories) = self.repositories {
            body.insert(
                "repositories".to_string(),
                serde_json::to_value(repositories).map_err(|e| FnoxError::ProviderApiError {
                    provider: "GitHub App".to_string(),
                    details: format!("Failed to serialize repositories: {e}"),
                    hint: "This is likely an internal bug.".to_string(),
                    url: URL.to_string(),
                })?,
            );
        }

Comment thread src/lease_backends/github_app.rs Outdated
Comment on lines +250 to +260
async fn revoke_lease(&self, lease_id: &str) -> Result<()> {
// Extract the token from cached credentials to revoke it.
// GitHub's revoke endpoint requires the token itself, not a lease ID.
// Since we use a generated lease_id (not the token), we can't revoke
// without the token value. The token auto-expires in ≤1 hour anyway.
tracing::debug!(
lease_id,
"GitHub App tokens auto-expire; skipping explicit revocation"
);
Ok(())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The revoke_lease implementation for the GitHub App backend is currently a no-op. While the lease is marked as revoked in the local fnox ledger (preventing further use by the tool), the actual installation access token remains valid on GitHub's servers until its natural expiration (which can be up to 1 hour). This means that if a token is compromised and a user attempts to revoke it, the attacker still has a window of opportunity to use the token.

To fix this, you should implement the revocation logic by calling GitHub's DELETE /installation/token endpoint. This requires the lease_id to be the installation token itself (or a way to retrieve it), and the revoke_lease method should use this token to authenticate the revocation request.

Comment thread src/lease_backends/mod.rs
Comment on lines +274 to +282
} => Ok(Box::new(github_app::GitHubAppBackend::new(
app_id.clone(),
installation_id.clone(),
private_key_file.clone(),
env_var.clone(),
permissions.clone(),
repositories.clone(),
api_base.clone(),
))),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

There are multiple .clone() calls here to create the GitHubAppBackend. While this is consistent with other backend instantiations in this file, it's inefficient as it allocates new memory for each cloned value.

A more efficient approach would be to change the create_backend method to take self by value instead of by reference (&self). This would allow moving the configuration values into the GitHubAppBackend::new function without needing to clone them. This would be a broader refactoring but would improve performance and is a better design pattern for this kind of factory function.

jdx and others added 2 commits March 8, 2026 20:29
Also sort and normalize dependency versions in Cargo.toml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jdx
Copy link
Copy Markdown
Owner Author

jdx commented Mar 9, 2026

bugbot run

Comment thread src/lease_backends/github_app.rs
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 9, 2026

Greptile Summary

The github-app lease backend implementation is correct and production-ready.

Strengths:

  • JWT generation correctly backdates iat by 60s and derives exp from iat (line 104), keeping the validity window to exactly 600s — within GitHub's 10-minute limit
  • Installation token creation, caching, and server-side revocation via DELETE /installation/token properly implemented
  • revoke_lease correctly handles None credentials during cleanup (line 271) by returning early
  • Configuration documentation accurately shows repository scoping with bare names, not owner/repo paths (line 128)
  • Test suite is comprehensive (9 tests) with mock HTTP server using OS-assigned ports for determinism
  • Proper error handling for 404 (already-revoked) and 422 (malformed config) responses

Known issue:

  • Dual jsonwebtoken versions (v9.3.1 via GCP IAM backend, v10 for this PR) compile into the binary, doubling the aws-lc-rs C-extension dependency. This impacts build times and binary size but does not affect functionality.

Core authentication, token lifecycle, and revocation logic are all sound.

Confidence Score: 4/5

  • Code is functionally correct and safe to merge; dual jsonwebtoken versions is a build/optimization concern, not a functional issue.
  • Implementation is solid with correct JWT lifetimes, proper token lifecycle management, and comprehensive tests. The only remaining concern is the dual compilation of jsonwebtoken (v9 and v10), which doubles the aws-lc-rs C-extension dependency and impacts build times/binary size. This is a non-critical optimization issue that does not block merging.
  • Cargo.toml (dual jsonwebtoken versions) — not critical but worth a note in release notes if binary size is a concern.

Last reviewed commit: eed87bd

Comment thread src/lease_backends/github_app.rs Outdated
Comment thread src/lease_backends/github_app.rs
Comment thread docs/leases/github-app.md Outdated
Comment thread src/lease_backends/github_app.rs
jdx and others added 2 commits March 8, 2026 20:39
GitHub's REST API requires a User-Agent header and rejects requests
without one with 403 Forbidden. Set it globally in http_client() so
all backends (including the new github-app lease backend) include it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Implement real token revocation via DELETE /installation/token
  (lease_id is now the token itself, not a random ID)
- Fix JWT lifetime: derive exp from iat (not now) to stay within
  GitHub's 600s limit when iat is backdated for clock drift
- Fix iss claim: parse app_id to integer (GitHub requires numeric)
- Fix repositories config: use bare names, not owner/repo paths
- Make required_env_vars() return empty vec since the env var is
  optional when private_key_file is configured
- Remove dead bash mock server code from test helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/lease_backends/github_app.rs Outdated
The lease_id field is never encrypted in the ledger and is visible in
lease list output. Previously, the GitHub installation token was stored
directly as the lease_id, bypassing credential encryption entirely.

Fix: use a blake3 hash of the token as the lease_id (deterministic,
non-secret), and extend the LeaseBackend::revoke_lease trait to accept
an optional credentials map. The revoke command now decrypts cached
credentials before passing them to the backend, so the GitHub App
backend can authenticate DELETE /installation/token with the actual
token from the encrypted credential store.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread src/commands/lease.rs
Comment thread src/lease_backends/github_app.rs Outdated
Comment thread test/lease_github_app.bats
Comment thread Cargo.toml
hex = "0.4"
hkdf = "0.12"
indexmap = { version = "2", features = ["serde"] }
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two jsonwebtoken versions now compile into the binary

Adding jsonwebtoken = { version = "10", … } alongside the existing jsonwebtoken 9.3.1 dependency (pulled in by the GCP IAM backend, visible in Cargo.lock) means both crates are compiled and linked. The jsonwebtoken crate links aws-lc-rs, so this doubles a large C-extension dependency.

If upgrading the GCP IAM backend to v10 is feasible, doing so would collapse the duplicate and keep compile times and binary size in check. If not, this is at minimum worth a comment noting the intentional dual-version situation.

Fix in Claude Code

…st ports

- Return Ok(()) when credentials are unavailable in revoke_lease instead
  of erroring (expired tokens can't be revoked server-side anyway)
- Use OS-assigned ports in tests to avoid conflicts in CI
- Fix shfmt formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment thread src/lease_backends/github_app.rs
Comment thread src/lease_backends/github_app.rs Outdated
Comment thread test/lease_github_app.bats
- Fix iss claim: JWT RFC 7519 defines iss as StringOrURI, so keep
  app_id as a string in the claims instead of parsing to u64
- Add fallback expiry of now + 1h when expires_at is missing or
  unparseable, preventing stale token reuse in the ledger
- Add revocation test: mock server now handles DELETE, test creates
  a lease then revokes it and verifies success

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jdx jdx enabled auto-merge (squash) March 9, 2026 01:27
@jdx jdx disabled auto-merge March 9, 2026 01:28
@jdx jdx merged commit 0579c8a into main Mar 9, 2026
16 checks passed
@jdx jdx deleted the feat/github-app-lease branch March 9, 2026 01:36
jdx added a commit that referenced this pull request Mar 9, 2026
## Summary

- Adds a new `github-app` lease backend that creates short-lived GitHub
installation access tokens from a GitHub App's private key
- Supports scoping tokens to specific permissions and repositories
- Configurable `env_var` (default: `GITHUB_TOKEN`), `api_base` for
GitHub Enterprise
- Tokens auto-expire in ≤1 hour (GitHub's hard limit), cached via
existing lease ledger

### Example config

```toml
[leases.github]
type = "github-app"
app_id = "12345"
installation_id = "67890"
private_key_file = "~/.config/fnox/github-app.pem"

[leases.github.permissions]
contents = "read"
pull_requests = "write"

repositories = ["my-org/my-repo"]
```

## Test plan

- [x] 9 bats tests covering: token creation (file + env var), `fnox
exec` integration, custom env_var, permissions/repositories config,
missing key error, lease list
- [x] All tests use a mock HTTP server (no real GitHub credentials
needed)
- [x] Existing cargo + bats tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new networked auth/crypto code for generating and revoking GitHub
tokens and changes the `LeaseBackend::revoke_lease` API across all
backends, so regressions could affect lease cleanup/revocation behavior.
> 
> **Overview**
> Adds a new **`github-app` lease backend** that creates GitHub App
installation access tokens by signing a short-lived JWT with an RSA
private key (from `FNOX_GITHUB_APP_PRIVATE_KEY` or `private_key_file`),
supports optional permission/repository scoping and custom `api_base`,
and generates non-secret `lease_id`s derived from a token hash.
> 
> Updates lease revocation to pass **decrypted cached credentials** into
`LeaseBackend::revoke_lease` (trait signature change applied across
existing backends) so backends can revoke using the actual credential
value; `lease cleanup` now explicitly skips passing creds for expired
leases. Also sets a default `User-Agent` on the shared `reqwest` client.
> 
> Extends config schema and docs to document the new backend, adds bats
tests with a mock GitHub API server, and updates dependencies
(`jsonwebtoken` v10 with `aws_lc_rs`, plus lockfile changes around
`untrusted`).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
eed87bd. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
jdx pushed a commit that referenced this pull request Mar 9, 2026
### 🚀 Features

- **(cloudflare)** add Cloudflare API token lease backend by
[@jdx](https://github.com/jdx) in
[#335](#335)
- **(fido2)** bump demand to v2, mask PIN during typing by
[@jdx](https://github.com/jdx) in
[#334](#334)
- **(init)** add -f as short alias for --force by
[@jdx](https://github.com/jdx) in
[#329](#329)
- **(lease)** add --all flag, default to creating all leases by
[@jdx](https://github.com/jdx) in
[#337](#337)
- **(lease)** add GitHub App installation token lease backend by
[@jdx](https://github.com/jdx) in
[#342](#342)

### 🐛 Bug Fixes

- **(config)** fix directory locations to follow XDG spec by
[@jdx](https://github.com/jdx) in
[#336](#336)
- **(exec)** use unix exec and exit silently on subprocess failure by
[@jdx](https://github.com/jdx) in
[#339](#339)
- **(fido2)** remove duplicate touch prompt by
[@jdx](https://github.com/jdx) in
[#332](#332)
- **(set)** write to lowest-priority existing config file by
[@jdx](https://github.com/jdx) in
[#331](#331)
- **(tui)** skip providers requiring interactive auth by
[@jdx](https://github.com/jdx) in
[#333](#333)

### 🛡️ Security

- **(ci)** retry lint step to handle transient pkl fetch failures by
[@jdx](https://github.com/jdx) in
[#341](#341)
- **(mcp)** add MCP server for secret-gated AI agent access by
[@jdx](https://github.com/jdx) in
[#343](#343)
- add guide for fnox sync by [@jdx](https://github.com/jdx) in
[#328](#328)

### 🔍 Other Changes

- share Rust cache across CI jobs by [@jdx](https://github.com/jdx) in
[#340](#340)
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.

1 participant