feat: add read-only GitHub App access alongside OAuth

This commit is contained in:
Thomas Durieux
2026-09-09 22:08:12 +02:00
parent e5c44845f0
commit a60ad6a921
49 changed files with 1754 additions and 139 deletions
+3
View File
@@ -121,3 +121,6 @@ tmp/
temp/
# End of https://www.gitignore.io/api/node
# GitHub App signing key
/secrets/github-app.pem
+4 -1
View File
@@ -72,9 +72,12 @@ AUTH_CALLBACK=http://localhost:5000/github/auth
- `GITHUB_TOKEN` — create one at <https://github.com/settings/tokens/new> with the `repo` scope.
- `CREDENTIAL_KEYS` / `CREDENTIAL_ACTIVE_KEY_ID` — generate a key with `openssl rand -base64 32`. Existing installations must follow the [credential migration guide](docs/credential-encryption.md) before starting this release.
- `CLIENT_ID` / `CLIENT_SECRET` — from a new GitHub App at <https://github.com/settings/applications/new>.
- `CLIENT_ID` / `CLIENT_SECRET` — from an OAuth App at <https://github.com/settings/applications/new>.
- The App's callback must be `https://<host>/github/auth` (matching `AUTH_CALLBACK`).
To enable read-only access to selected private repositories alongside OAuth, follow
the [GitHub App setup guide](docs/github-app-setup.md).
**3. Start the server**
```bash
+9
View File
@@ -0,0 +1,9 @@
# Enable with: docker compose -f docker-compose.yml -f docker-compose.github-app.yml up -d
# App settings are supplied through the existing .env file for both services.
services:
anonymous_github:
volumes:
- ./secrets/github-app.pem:/run/secrets/github-app.pem:ro
streamer:
volumes:
- ./secrets/github-app.pem:/run/secrets/github-app.pem:ro
+133
View File
@@ -0,0 +1,133 @@
# GitHub App migration with OAuth coexistence
Status: proposed implementation plan. No authentication behavior changes in this document.
## Outcome and scope
Add a GitHub App that reads selected private repositories, while keeping the existing OAuth App operational throughout the transition. Users can connect both to the same Anonymous GitHub account and migrate individual anonymized resources without changing their URLs, ownership, settings, or cached content. Make the GitHub App the preferred connection after validation; removing OAuth is a separate future decision.
GitHub Apps support granular read permissions and installation repository selection. The existing OAuth `repo` scope cannot provide the equivalent read-only private repository grant. A GitHub App still uses an OAuth web flow for user authorization; the migration changes the application and credential model, not the underlying sign-in protocol. See [GitHub's comparison](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps).
Read-only describes access to GitHub. Anonymizing a private repository still publishes the configured anonymized content through this service; preserve that explanation in the creation flow.
## Current implementation and affected areas
| Area | Current behavior | Required change |
| --- | --- | --- |
| `src/server/routes/connection.ts` | `/github/login` requests `repo`; `/github/auth` saves one GitHub token; sessions contain the local user ID | Separate provider callbacks and credential writes, shared account identity |
| `src/core/credentials.ts`, `model/credentials/credentials.model.ts`, `credential-crypto.ts` | Encrypted credentials indexed by owner and provider; provider enum only accepts `github` | Preserve existing OAuth envelopes and add distinct App user credentials |
| `src/core/GitHubUtils.ts` | OAuth reset after seven days, `/user` token check, global `GITHUB_TOKEN` fallback; throttling keyed by token suffix | Provider-specific validation/renewal, explicit access resolution, stable rate-limit identities |
| `src/core/User.ts` | Repository discovery through `/user/repos`, cached on the user | Discover App installations and user-accessible repositories; merge provider availability |
| `src/server/routes/repository-private.ts`, `src/core/Repository.ts` | Creation, claim, preview, branches, updates and diagnostics pass owner tokens | Resolve the selected connection consistently and persist resource bindings |
| `src/core/source/GitHubRepository.ts`, `GitHubBase.ts`, `GitHubDownload.ts`, `GitHubStream.ts` | Metadata, commits, README, Pages, trees, raw content and archives use token-based clients | Use renewable access contexts across requests and stream retries |
| `src/core/PullRequest.ts`, `src/core/Gist.ts` and their routes/models | Separate owner-token lookup and fallback paths | Explicit provider handling; retain OAuth compatibility for both features |
| `src/queue`, `src/streamer`, scheduler callers | Downloads and updates run without an interactive session | Resolve bindings at execution time, renew tokens, stop on revoked access |
| `src/server/routes/user.ts` | Account removal revokes the OAuth grant | Revoke each user grant using its own client; remove local bindings |
| `src/config.ts`, Compose files, `README.md`, frontend partials/scripts/locales | OAuth-only configuration and connection UI | Dual configuration, connection management, deployment instructions |
The README currently calls the registration at `/settings/applications/new` a GitHub App; implementation documentation should identify it as an OAuth App and provide a separate GitHub App registration procedure.
## Proposed access model
### Separate identity, user authorization, and installation access
Use the GitHub numeric user ID in `externalIDs.github` to resolve the same local account for either login. Preserve local IDs, admin flags, quotas, coauthors and API tokens. App linking must reject a different GitHub identity and disabled accounts. Do not automatically link App accounts by username; handle legacy accounts without a verified GitHub ID through explicit recovery.
Use an App user access token for identity, installation discovery, and verification that the connecting user can access the selected repository. Use an installation access token for bound repository downloads and scheduled updates. Installation access is independent of the individual user, so an installation ID supplied by a browser is never sufficient authorization. GitHub documents the user/app permission intersection in [user token generation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app).
For a new binding or a manual source change, verify the local owner and the GitHub user's current access through the App user token. For background synchronization, revalidate that owner's repository access at the start of each job/update cycle, with a short bounded cache for streaming requests. Refresh the user grant server-side; if it expires or is revoked, pause new source access until reauthorization. This deliberately prevents an organization installation from preserving a departed user's ability to synchronize private content. Coauthor permission to manage an anonymized resource does not grant permission to attach another installation or replace its owner's connection.
### Permissions
Register a public-installable GitHub App for personal and organization accounts. Recommend “Only select repositories” during installation.
| Permission | Level | Purpose |
| --- | --- | --- |
| Repository metadata | Read | Repository identity and metadata |
| Contents | Read | Commits, branches, trees, README, files and archives |
| Pull requests | Read | Preserve PR metadata and diff support |
| Pages | Read | Preserve the current Pages source lookup |
Request no repository write permissions. Verify PR issue-comment reads with the Pull requests permission before finalizing the manifest; add Issues read only if an actual required endpoint demands it. Private email access is unnecessary for the initial migration; preserve available profile data and tolerate absent email addresses.
Pages lookup specifically requires Pages read; lack of Pages access must not make ordinary repository import fail. See [Pages endpoint permissions](https://docs.github.com/en/rest/pages/pages#get-a-github-pages-site).
Gists are a separate capability. Keep existing gist resources on OAuth in the first release, and show that requirement for App-only users. Do not send installation tokens to gist endpoints. Evaluate App user-token support for gist content, comments and raw downloads as a later compatibility step using the [gist API reference](https://docs.github.com/en/rest/gists/gists). OAuth retirement depends on resolving this gap.
### Persistence and credential lifecycle
- Keep `provider: "github"` meaning legacy OAuth. Add `github-app-user` with encrypted access and refresh tokens, expiry timestamps, grant status and a concurrency version. Login through either provider must not overwrite the other.
- Preserve the existing ciphertext/AAD contract for OAuth. Extend encryption with a distinct field/purpose binding for refresh tokens so access and refresh ciphertexts cannot be swapped. Extend credential verification, migration tooling, hidden projections and redaction accordingly.
- Add an installations collection keyed by App ID and installation ID, recording GitHub account ID/type, repository selection, permissions, suspension/deletion status and reconciliation time. Store user-to-installation associations separately; organization installations can serve multiple local users.
- Add an explicit access binding to anonymized repositories and PRs: `kind` (`oauth` or `github-app`), local credential owner, stable GitHub repository ID, and installation ID for App resources. A missing binding means legacy OAuth during compatibility rollout. Keep bindings out of public anonymized responses.
- Treat shared repository metadata as metadata, never as proof of access. Partition discovery/authorization caches by user, provider and installation; invalidate them on connection changes.
- Store no installation tokens in MongoDB resource records, sessions, job payloads, public responses or URLs. Cache them briefly in process, keyed by App/installation/repository/permissions, with expiry skew and shared in-flight minting. Give workers and streamers secure access to the App key and resolver.
Mint installation tokens restricted to the bound repository and required read permissions. GitHub installation tokens expire after one hour; renew before expiry and retry an idempotent read once after an authentication failure. Long downloads must reacquire archive URLs/tokens when restarting rather than persist signed URLs. See [installation token generation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app).
Enable expiring App user tokens. Store returned expiry values, rotate access/refresh pairs atomically, and serialize refresh across processes to avoid consuming the same refresh token twice. GitHub currently documents eight-hour access tokens and six-month refresh tokens. Keep this separate from the existing OAuth reset endpoint. See [refreshing user tokens](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens).
### Provider selection and failure behavior
Introduce a central access service with operations for user identity, repository discovery, repository access and resource access. Return an authenticated client/access context with provider, repository scope and expiry, rather than letting callers guess a token type. Installation tokens cannot be validated with the current `/user` check.
| Situation | Behavior |
| --- | --- |
| Existing resource without binding | Resolve legacy OAuth |
| New resource with verified App access | Default to App; record binding |
| New resource available through both | Prefer App, allow explicit OAuth selection |
| Resource explicitly bound to OAuth | Continue OAuth until the owner migrates it |
| App unavailable, suspended, revoked or repository deselected | Pause source access; show reconnect/configure action |
| Organization approval pending | Keep the existing OAuth resource working; leave migration pending |
| No private repository grant | Deny source access; do not use another user's or global token |
Never silently fall back from an App binding to OAuth or `GITHUB_TOKEN`. This prevents a removed App grant from being bypassed by broader retained credentials. Restrict any public fallback path to independently verified public resources. Preserve CLI user-supplied token support as a distinct context.
Keep rate-limit behavior across token renewal by using provider-aware quota identities (installation for installation access, GitHub user for user access), while retaining GitHub response-driven backoff. Do not log token suffixes as identifiers in new paths. Distinguish expired credentials, revoked access, missing repository, missing permission, rate limit and transient upstream failures.
## User transition
1. Add “Connect GitHub App — read-only repository access” alongside “Connect with OAuth — legacy repository access.” Retain the existing OAuth callback URL; introduce dedicated App authorize/callback/setup routes.
2. Bind authorization and installation setup to short-lived, single-use session state with a fixed local return path. Validate installation ownership/access through GitHub APIs after callback. Support installations initiated on GitHub, canceled flows and organization approval delays without trusting setup query parameters.
3. Discover installations using the App user token and paginate `/user/installations/{installation_id}/repositories`. Merge with OAuth discovery by GitHub repository ID, showing connection availability without exposing installation-wide repositories the user cannot access. See [installation endpoints](https://docs.github.com/en/rest/apps/installations).
4. Provide a migration preview listing eligible resources and blocked resources, with reasons such as repository not selected, approval pending, missing permission or gist compatibility.
5. On explicit migration, verify App access and read the resource's configured commit (or PR) before conditionally updating its binding. Preserve all anonymization settings, URLs and caches. A partial batch reports per-resource results and can be rerun; jobs read the latest binding and reject stale results if the binding changes while running.
6. Display each resource's connection and reconnect action. Connecting the App alone does not migrate resources or revoke OAuth.
7. Offer OAuth disconnect after listing remaining dependencies, including gists. A new sign-in does not reduce an existing OAuth grant's scope. Complete removal of the broad grant requires explicit revocation; warn about affected resources in that concrete disconnect flow.
## Revocation, webhooks and retained content
Add a webhook route that verifies `X-Hub-Signature-256` against the raw body before JSON processing. Deduplicate deliveries, queue durable processing, and reconcile authoritative state for delayed/out-of-order events. Handle installation creation/deletion/suspension/unsuspension, repository selection changes and user authorization revocation. See [GitHub webhook events](https://docs.github.com/en/webhooks/webhook-events-and-payloads).
Invalidate tokens, discovery caches and access checks when a grant changes; block new upstream reads for affected resources. Reconcile periodically and on access failures so missed webhooks do not indefinitely preserve access. Repositories transferred to a different installation require revalidation and explicit rebinding; numeric repository IDs remain the identity across renames.
Proposed retention policy: revocation pauses source synchronization but preserves already published anonymized snapshots under existing expiration/removal rules. Show that policy when connecting/disconnecting. Account/resource removal continues deleting content according to current behavior. On account deletion, revoke both user grants and remove that user's bindings, but do not uninstall a shared organization installation or erase other users' connections.
## Implementation sequence and release gates
1. **Access abstraction and compatibility.** Inventory all GitHub calls, add provider-aware contexts and explicit fallback rules, adapt OAuth callers, and add optional binding fields. Gate: existing OAuth, CLI, API-token login, repository, gist and PR paths pass without requiring App configuration.
2. **App infrastructure.** Add installation/grant models, encryption extensions, token issuance/refresh and dual configuration. Proposed settings: `GITHUB_APP_ENABLED`, `GITHUB_APP_ID`, `GITHUB_APP_SLUG`, `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_PRIVATE_KEY_FILE`, `GITHUB_APP_CALLBACK`, `GITHUB_APP_WEBHOOK_SECRET`; retain `CLIENT_ID`, `CLIENT_SECRET`, `AUTH_CALLBACK` for OAuth. Validate only enabled providers. Gate: test App accesses a selected private repository with read permissions and cannot access an unselected one.
3. **Connection and lifecycle.** Add App login/link/setup, verified discovery, webhooks, reconciliation, account deletion and frontend connection states. Gate: both logins resolve the same account; multi-user organization installations cannot leak access; cancellation and pending approval preserve OAuth behavior.
4. **Repository and PR integration.** Route creation, previews, branch/commit/Pages reads, updates, claims, admin diagnostics, streams, archives and queued work through the resolver. Gate: App-only private repository and PR workflows succeed, including renewal during delayed jobs; gist compatibility is clearly represented.
5. **Migration UX and tooling.** Add preview, per-resource switching, OAuth disconnect dependency checks, provider labels and retryable batch results. Any administrative migration tool defaults to dry-run and requires verified owner authorization before switching bindings. Gate: mixed-provider resources for one account operate independently without changing public URLs.
6. **Staged rollout.** Deploy schema/read compatibility to all API instances, workers and streamers before enabling App writes. Enable for maintainers, then an opt-in cohort, then make App the new-connection default. Keep separate controls for new App connections/migrations and serving existing App bindings. Publish setup, key rotation and recovery instructions in README/docs and Compose configuration.
Do not relabel or re-encrypt existing `github` credentials in bulk. An optional idempotent backfill can mark legacy bindings as OAuth after every deployed reader supports them. Extend the existing credential migration verifier before App credentials are written, so it does not misclassify their provider or envelope format.
Monitor success/error rates by provider, token issuance/refresh failures, permission failures, paused resources, webhook lag and migration outcomes. Never include private names, installation metadata or credentials in public error responses. Advance rollout only after the cohort exercises token expiration, queued work and revocation without regressions.
Rollback disables new App connections and migrations while keeping the dual-provider resolver running for already migrated resources. Roll back only to an App-aware release once bindings exist. Switching a resource back to OAuth requires explicit owner selection and a verified remaining OAuth grant; restoring a global fallback is not a rollback mechanism.
## Validation checklist
- Both login providers, account linking, disabled/deleted accounts, mismatched identity, callback state replay, unchanged ID-only sessions and API-token login.
- OAuth-only, App-only and mixed accounts; personal/organization installs; selected/all repositories; pagination; users sharing an installation with different repository access.
- Private metadata, branches, README, commit/tree/raw reads, archives, streaming, Pages and PR comments/diff; preserve OAuth gist and CLI behavior.
- App user refresh rotation under concurrency; installation token expiry during queued work and retries; transient GitHub errors and shared rate-limit backoff.
- Forged installation/repository IDs, cached private metadata, coauthor/admin paths and all global-token fallback branches.
- Suspension, deselection, uninstall, user grant revocation, lost organization membership, repository rename/transfer, webhook duplicates/out-of-order delivery and missed-event reconciliation.
- Migration cancellation, partial success, reruns and in-flight jobs; stable anonymous URLs and options; OAuth disconnect dependency detection; account deletion with shared installations.
- MongoDB encryption/projection/migration integration tests, token redaction and no secrets in sessions, queues, responses or stored archive URLs.
- Run `npm test`, `npm run lint`, `npm run build`, relevant UI checks, and existing MongoDB integration tests with a disposable `TEST_MONGODB_URI`. Perform sandbox GitHub App acceptance checks against disposable repositories before rollout; inspect granted permissions rather than issuing writes against real repositories.
Completion means existing OAuth users continue operating, App users can anonymize selected private repositories using only read permissions, users can migrate resource by resource, and revocation/expiry never bypasses the chosen connection.
+150
View File
@@ -0,0 +1,150 @@
# GitHub App setup and OAuth transition
The GitHub App is opt-in for operators. Existing OAuth configuration and resources
continue working with `GITHUB_APP_ENABLED=false` (the default). This feature uses
existing repository streaming/proxy paths and does not introduce ZIP uploads or
permanent repository copies.
## Register the GitHub App
Create a GitHub App at https://github.com/settings/apps/new. This is separate from
the existing OAuth App; retain its client credentials and callback.
Configure:
- Callback URL: `https://YOUR_HOST/github/app/callback`.
- Setup URL: `https://YOUR_HOST/github/app/setup`; enable **Redirect on update**.
- Webhook URL: `https://YOUR_HOST/github/app/webhook`; enable webhooks and generate
a strong random webhook secret.
- Repository permissions: **Contents: read-only**, **Metadata: read-only**,
**Pull requests: read-only**, **Pages: read-only**. Do not grant write permissions.
Pull requests read also permits [reading PR issue comments](https://docs.github.com/en/rest/issues/comments#list-issue-comments); Issues permission is unnecessary.
- Keep user access token expiration enabled. Private email permission is unnecessary.
- Leave **Request user authorization (OAuth) during installation** unchecked.
Anonymous GitHub authorizes the user before opening installation; the setup
redirect must remain available afterward.
- Allow installation on any account if this is a public service.
- Generate a private key and record the App ID, slug, client ID and client secret.
GitHub delivers installation, installation repository selection, and App user
revocation lifecycle events. The webhook verifies raw request bytes before parsing
JSON. Failed processing returns an error for operational visibility; use GitHub's
delivery redelivery controls after an outage. Pending installation checks also
retry on repository access. Access checks verify the user's current repository
access before each new upstream access.
GitHub documents [registration](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app),
[return redirects](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-setup-url),
and [repository preselection for OAuth migration](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/migrating-oauth-apps-to-github-apps).
## Environment and deployment
Set these on every API process, background worker and streamer:
```dotenv
GITHUB_APP_ENABLED=true
GITHUB_APP_NEW_CONNECTIONS=true
GITHUB_OAUTH_ENABLED=true
GITHUB_APP_ID=123456
GITHUB_APP_SLUG=your-app-slug
GITHUB_APP_CLIENT_ID=Iv.your-client-id
GITHUB_APP_CLIENT_SECRET=your-client-secret
GITHUB_APP_CALLBACK=https://YOUR_HOST/github/app/callback
GITHUB_APP_WEBHOOK_SECRET=your-random-webhook-secret
GITHUB_APP_PRIVATE_KEY_FILE=/run/secrets/github-app.pem
```
Supply the PEM key through a read-only secret mount at that path on **both**
`anonymous_github` and `streamer`. Alternatively, `GITHUB_APP_PRIVATE_KEY` accepts
the actual multiline PEM value from your secret manager. Never commit the PEM or
secrets. The optional Compose override `docker-compose.github-app.yml` mounts
`./secrets/github-app.pem` in both services:
```sh
docker compose -f docker-compose.yml -f docker-compose.github-app.yml up -d --build
```
Keep the existing `CLIENT_ID`, `CLIENT_SECRET`, `AUTH_CALLBACK`, `SESSION_SECRET`
and credential encryption keyring unchanged. The App adds encrypted credentials
under `github-app-user`; legacy `github` credentials remain OAuth credentials.
Refresh tokens have a distinct authenticated encryption purpose. The existing
credential verification command now checks both envelopes.
Deploy this release to all readers/workers **before** enabling App connections.
Existing records without a connection binding retain OAuth behavior; no bulk
credential migration is necessary for an already encryption-capable deployment.
Do not rerun plaintext cleanup just to enable the App.
## User flow
Sign in offers App and OAuth choices. Both resolve the same existing account by
its GitHub numeric user ID. A signed-in user cannot attach a different GitHub
identity. Legacy accounts without a verified GitHub ID require account recovery;
App login never automatically links by username or email.
On the anonymization form, **Connect read-only GitHub access** starts user
authorization and then repository installation. **Allow repository access on
GitHub** opens installation/configuration directly, preserving the current draft
in this browser tab for 30 minutes. Confirm access on GitHub and return to the
form. Existing installations have direct account-specific configuration links.
GitHub may require organization administrator approval. Use **Refresh access
after approval** on the Connections page when approval is delayed.
App-connected accounts default to the App for new repository/PR access. The
explicit **Use existing OAuth access** choice handles repositories not yet
available through the App. An App error never silently selects OAuth. Gists
continue using OAuth in this release.
**GitHub connections** lists each resource's current connection. First check
read-only access, then switch the resource. The switch validates the existing
commit or PR and conditionally updates the binding; it preserves the anonymous
URL, settings and existing content. Busy resources must finish before switching.
Each resource can be switched back to a verified existing OAuth grant.
OAuth can be explicitly revoked once all dependent resources, including gists,
have been migrated or removed and a working App sign-in remains. Merely connecting
the App does not revoke or narrow the OAuth grant.
## Access lifecycle and rollback
Installation tokens are short-lived, minted for one repository, and cached only
in process. Encrypted App user grants allow server-side renewal without a browser
session. Token refresh is serialized through MongoDB with conditional writes,
so a concurrent login or revocation cannot be overwritten. Background source
reads verify the owner's current App access; losing user access stops new reads.
App-bound resources never use the global token or another user's grant.
Uninstall, deselection, suspension and user grant revocation block new upstream
reads. Already published anonymized content follows existing expiration/removal
settings. Account removal revokes the user's grants and removes their resources;
it does not uninstall a shared organization installation.
Failed installation webhook checks remain pending in MongoDB and are retried on
the next repository access. Access stays blocked until reconciliation succeeds.
Revision checks prevent older responses from undoing newer lifecycle events.
User-revocation events verify the current grant, so delayed deliveries cannot
revoke a working grant created by reconnecting.
Set `GITHUB_APP_NEW_CONNECTIONS=false` to pause new App sign-ins/installations and
migrations while continuing to serve existing App resources. Setting
`GITHUB_APP_ENABLED=false` also disables existing App access; it does not fall
back to OAuth. After creating App bindings, rollback must stay on an App-aware
release with the same encryption keys. OAuth removal is not part of this release.
## Validation before enabling production
Run `npm test`, `npm run lint`, `npm run build`, and `npm run test:ui`.
MongoDB integration tests require a disposable test server:
```sh
TEST_MONGODB_URI=mongodb://127.0.0.1:27029 npm test
```
The tests create and drop separately named databases. Validate the configured App
against a disposable private GitHub repository: sign in, install with only that
repository selected, import, view files, refresh, migrate an OAuth resource, and
remove App access. Verify a second unselected private repository is inaccessible.
Exercise a private PR including comments and a Pages-enabled repository. Confirm
signed webhook deliveries arrive successfully and that reconnecting restores
access. Live GitHub acceptance requires a registered App and cannot be simulated
by the local test suite.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"core.min.js": "core.c5bd53363a.min.js",
"vendor.min.js": "vendor.0f10f9accb.min.js",
"vendor.min.js": "vendor.b17b42ea2b.min.js",
"mermaid.min.js": "mermaid.f848a72d16.min.js",
"all.min.css": "all.7ac8730b9a.min.css",
"markdown.min.js": "markdown.ad7b1d71c3.min.js",
+22 -1
View File
@@ -114,7 +114,28 @@
"storage_read_error": "An error occurred while reading the file from storage — please try again.",
"upstream_error": "A temporary error occurred while fetching from GitHub — please try again.",
"token_expired": "Your GitHub access token has expired. Please log out and log in again to refresh it.",
"job_is_active": "This job is currently running — wait for it to finish or remove it first."
"job_is_active": "This job is currently running — wait for it to finish or remove it first.",
"github_app_access_required": "Allow this repository on GitHub using the read-only access button, then retry. Organization access may require administrator approval.",
"github_app_reconnect_required": "Reconnect your GitHub App account to restore read-only access.",
"github_app_disabled": "New GitHub App connections are currently unavailable.",
"github_oauth_required": "Connect legacy GitHub OAuth to use this feature.",
"github_identity_mismatch": "Use the same GitHub account that you are signed in with here.",
"github_app_permissions_invalid": "The GitHub App must be configured with read-only permissions.",
"github_unavailable": "GitHub is temporarily unavailable. Please retry.",
"connection_changed": "The repository connection changed. Reload and try again.",
"repository_busy": "Wait for the repository operation to finish before changing its connection.",
"invalid_connection": "Select a supported GitHub connection.",
"github_oauth_disabled": "Legacy OAuth sign-in is currently disabled.",
"invalid_auth_state": "The authorization session expired. Please start again from Anonymous GitHub.",
"invalid_webhook_signature": "The webhook signature is invalid.",
"webhook_processing_failed": "Webhook processing failed. Retry the delivery.",
"github_app_authorization_cancelled": "GitHub authorization was cancelled.",
"github_account_link_required": "This username belongs to an existing account. Sign in to that account before connecting GitHub.",
"github_app_refresh_busy": "GitHub credentials are being renewed. Please retry shortly.",
"github_grant_revocation_failed": "Unable to revoke the GitHub grant. Please retry.",
"another_login_required": "Connect and verify another sign-in method before disconnecting OAuth.",
"oauth_resources_remaining": "Some resources still require OAuth. Migrate or remove them before disconnecting.",
"invalid_github_path": "The GitHub repository path is invalid."
},
"WARNINGS": {
"page_not_enabled_on_repo": "GitHub Pages is not enabled on this repository. Enable it in the repository's Settings → Pages on GitHub, then refresh.",
+9
View File
@@ -1,4 +1,13 @@
<div class="anonymize-page h-100">
<div class="container py-3" v-if="githubConnections?.appEnabled">
<span v-if="isUpdate">Connection: {{ githubConnection === 'github-app' ? 'GitHub App (read-only)' : 'Legacy OAuth' }}. <a href="/connections">Change connection</a></span>
<span v-if="!isUpdate">Repository access:
<button class="btn" :disabled="githubConnection === 'github-app'" @click="chooseGitHubConnection('github-app')">Read-only GitHub App</button>
<button v-if="githubConnections?.oauthConnected" class="btn" :disabled="githubConnection === 'oauth'" @click="chooseGitHubConnection('oauth')">Use existing OAuth access</button>
</span>
<button class="btn btn-ink" @click="grantGitHubAccess()">{{ githubConnections?.appConnected ? 'Allow repository access on GitHub' : 'Connect read-only GitHub access' }}</button>
<p class="mb-0"><small>Return here after confirming access on GitHub. Anonymization publishes the configured content, including content from private repositories. Gists currently use OAuth.</small></p>
</div>
<!-- ===== STATE 1: No URL — centered input ===== -->
<div class="anonymize-landing" v-show="!(sourceUrl)">
<div class="anonymize-landing-inner">
+44
View File
@@ -0,0 +1,44 @@
<div class="container page paper-page">
<div class="paper-crumbs"><a href="/dashboard">My work</a> / GitHub connections</div>
<h1 class="paper-page-title">GitHub <em>connections</em></h1>
<p>Connect the GitHub account you use here. Your anonymizations and URLs stay the same.</p>
<p class="alert alert-danger" role="alert" v-if="connectionError">{{ connectionError }}</p>
<div v-if="connections">
<section class="paper-settings-section" v-if="connections.appEnabled">
<h2>Read-only GitHub App</h2>
<p v-if="connections.appError" role="alert">{{ connections.appError }}. Reconnect to restore access.</p>
<a class="btn btn-ink" target="_self" href="/github/app/login">{{ connections.appConnected ? 'Reconnect GitHub account' : 'Connect GitHub account' }}</a>
<a class="btn" v-if="connections.appConnected" target="_self" href="/github/app/install">Allow repositories on GitHub</a>
<p class="mt-2">GitHub opens directly to the access screen. Select repositories and confirm. Organization access may require an administrator's approval.</p>
<ul>
<li v-for="installation in connections.installations" :key="installation.id">
{{ installation.account }} {{ installation.suspended ? '(suspended)' : '' }} —
<a target="_self" :href="'/github/app/install?installationId=' + installation.id">Add or remove repository access</a>
</li>
</ul>
<button class="btn" :disabled="busy" @click="loadConnections()">Refresh access after approval</button>
</section>
<section class="paper-settings-section">
<h2>Legacy OAuth</h2>
<p>{{ connections.oauthConnected ? 'Connected' : 'Not connected' }}. Existing OAuth anonymizations continue to use this connection.</p>
<a v-if="connections.oauthEnabled" target="_self" href="/github/login" class="btn">Connect legacy OAuth</a>
<p v-if="connections.gistCount">{{ connections.gistCount }} gist(s) still require OAuth.</p>
<button v-if="connections.oauthConnected &amp;&amp; connections.appConnected" class="btn" :disabled="busy || connections.gistCount || connections.resources.some(r => r.connection === 'oauth')" @click="disconnectOAuth()">Revoke OAuth access</button>
<p>Migrate all dependent resources before revoking OAuth. Revocation removes GitHub's broad OAuth repository grant.</p>
</section>
<section class="paper-settings-section">
<h2>Repository connections</h2>
<p>Check read-only access, then switch each resource. Connecting the App alone does not change existing resources.</p>
<p>Removing GitHub access stops fetching new source content. Already published anonymized content follows its existing expiration and removal settings.</p>
<div v-for="resource in connections.resources" :key="resource.type + resource.id" class="mb-3">
<strong>{{ resource.id }}</strong> — {{ resource.name }} ({{ resource.type }})<br>
Connection: {{ resource.connection === 'github-app' ? 'GitHub App (read-only)' : 'Legacy OAuth' }}
<div v-if="resource.connection === 'oauth' &amp;&amp; connections.appEnabled &amp;&amp; connections.appConnected">
<button class="btn" :disabled="busy" @click="changeConnection(resource, 'github-app', true)">Check read-only access</button>
<button v-if="resource.eligible" class="btn btn-ink" :disabled="busy" @click="changeConnection(resource, 'github-app', false)">Switch to read-only access</button>
</div>
<button v-if="resource.connection === 'github-app' &amp;&amp; connections.oauthConnected" class="btn" :disabled="busy" @click="changeConnection(resource, 'oauth', false)">Switch to existing OAuth access</button>
</div>
</section>
</div>
</div>
+2
View File
@@ -12,6 +12,8 @@
</a>
</div>
<p class="mt-3"><a href="/connections">Manage GitHub connections and read-only access</a></p>
<!-- Quota -->
<div class="quota-row" v-if="quota">
<div class="quota-item" v-for="(q, index) in [
+2 -1
View File
@@ -64,7 +64,7 @@
</li>
<li class="nav-item" v-if="!user">
<a class="nav-link btn-signin" target="_self" href="/github/login" data-offset="30"><i class="fab fa-github mr-1"></i>
<a class="nav-link btn-signin" target="_self" href="/signin" data-offset="30"><i class="fab fa-github mr-1"></i>
Sign in
</a>
</li>
@@ -75,6 +75,7 @@
</a>
<div class="dropdown-menu dropdown-menu-right user-menu" aria-labelledby="navbarDropdownMenuLink">
<h6 class="dropdown-header">Signed in as @{{ user?.username }}</h6>
<a class="dropdown-item" href="/connections">GitHub connections</a>
<a class="dropdown-item" href="/profile">
<i class="fas fa-sliders-h" aria-hidden="true"></i>
<span>Settings<small>Defaults, quotas, account</small></span>
+1 -1
View File
@@ -14,7 +14,7 @@
</p>
<div class="paper-cta-row">
<a href="/github/login" target="_self" class="btn-hero" v-if="!user">
<a href="/signin" target="_self" class="btn-hero" v-if="!user">
<i class="fab fa-github mr-2" aria-hidden="true"></i>Sign in with GitHub
</a>
<a href="/anonymize" class="btn-hero" v-if="user">
+8
View File
@@ -0,0 +1,8 @@
<div class="container page paper-page">
<h1 class="paper-page-title">Connect to <em>GitHub</em></h1>
<p>Sign in to manage your anonymizations. Both connections use the same Anonymous GitHub account.</p>
<p v-if="site_options?.GITHUB_APP_ENABLED"><a class="btn btn-ink" target="_self" href="/github/app/login">Continue with read-only GitHub App</a></p>
<p v-if="site_options?.GITHUB_APP_ENABLED">Choose which repositories to allow after signing in. Private repositories are accessed with read-only permissions.</p>
<p v-if="site_options?.GITHUB_OAUTH_ENABLED"><a class="btn" target="_self" href="/github/login">Continue with legacy GitHub OAuth</a></p>
<p v-if="site_options?.GITHUB_OAUTH_ENABLED">OAuth supports existing connections and gists. GitHub's private repository OAuth scope includes write access.</p>
</div>
+87 -11
View File
@@ -888,6 +888,22 @@ export const statusController = function (state, http, params) {
export const anonymizeController = function (state, http, html, params, location, translate, timeout) {
// Unified state
state.sourceUrl = "";
state.githubConnection = undefined;
state.githubConnections = null;
state.grantGitHubAccess = () => {
const draft = {};
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) draft[key] = state[key];
sessionStorage.setItem("github-access-draft", JSON.stringify({ path: location.path(), savedAt: Date.now(), draft }));
const returnTo = location.path();
const repository = parseRepoFullName(state.sourceUrl) || "";
const route = state.githubConnections?.appConnected ? "/github/app/install" : "/github/app/login";
window.location.href = route + "?returnTo=" + encodeURIComponent(returnTo) + "&repository=" + encodeURIComponent(repository) + "&install=1";
};
state.chooseGitHubConnection = async (value) => {
state.githubConnection = value;
state.readme = "";
if (state.sourceUrl) await refreshGitHubAccess();
};
state.detectedType = null; // 'repo' | 'pr' | 'gist'
state.repoId = "";
state.pullRequestId = "";
@@ -984,7 +1000,28 @@ export const anonymizeController = function (state, http, html, params, location
: undefined;
}
async function refreshGitHubAccess() {
state._preservingDraft = true;
try { await state.urlSelected(true); }
finally { state._preservingDraft = false; }
}
async function restoreGitHubDraft() {
let saved;
try { saved = JSON.parse(sessionStorage.getItem("github-access-draft") || "null"); }
catch (_) { return false; }
if (!saved || saved.path !== location.path() || Date.now() - saved.savedAt >= 30 * 60000) return false;
for (const key of ["sourceUrl", "terms", "repoId", "pullRequestId", "gistId", "source", "options", "conference", "githubConnection"]) {
if (Object.prototype.hasOwnProperty.call(saved.draft, key)) state[key] = saved.draft[key];
}
if (state.options.expirationDate) state.options.expirationDate = new Date(state.options.expirationDate);
await refreshGitHubAccess();
sessionStorage.removeItem("github-access-draft");
return true;
}
getDefault(() => {
if (!params.repoId && !params.pullRequestId && !params.gistId) timeout(restoreGitHubDraft, 0);
// Edit mode: repo
if (params.repoId && params.repoId != "") {
state.isUpdate = true;
@@ -992,6 +1029,7 @@ export const anonymizeController = function (state, http, html, params, location
state.repoId = params.repoId;
http.get("/api/repo/" + state.repoId).then(
async (res) => {
state.githubConnection = res.data.connection || "oauth";
state.sourceUrl = "https://github.com/" + res.data.source.fullName;
state._originalFullName = res.data.source.fullName;
state.terms = res.data.options.terms.filter((f) => f).join("\n");
@@ -1012,6 +1050,7 @@ export const anonymizeController = function (state, http, html, params, location
if (res.data.options.expirationDate) {
state.options.expirationDate = new Date(res.data.options.expirationDate);
}
if (await restoreGitHubDraft()) return;
await Promise.all([getRepoDetails(), getReadme()]);
anonymizeReadme();
@@ -1026,6 +1065,7 @@ export const anonymizeController = function (state, http, html, params, location
state.pullRequestId = params.pullRequestId;
http.get("/api/pr/" + state.pullRequestId).then(
async (res) => {
state.githubConnection = res.data.connection || "oauth";
state.sourceUrl = "https://github.com/" + res.data.source.repositoryFullName + "/pull/" + res.data.source.pullRequestId;
state.terms = res.data.options.terms.filter((f) => f).join("\n");
state.source = res.data.source;
@@ -1035,8 +1075,9 @@ export const anonymizeController = function (state, http, html, params, location
if (res.data.options.expirationDate) {
state.options.expirationDate = new Date(res.data.options.expirationDate);
}
if (await restoreGitHubDraft()) return;
try {
state.details = (await http.get(`/api/pr/${res.data.source.repositoryFullName}/${res.data.source.pullRequestId}`)).data;
state.details = (await http.get(`/api/pr/${res.data.source.repositoryFullName}/${res.data.source.pullRequestId}`, { params: { connection: state.githubConnection } })).data;
} catch (error) {
const code = error && error.data && error.data.error;
if (code) {
@@ -1068,6 +1109,7 @@ export const anonymizeController = function (state, http, html, params, location
if (res.data.options.expirationDate) {
state.options.expirationDate = new Date(res.data.options.expirationDate);
}
if (await restoreGitHubDraft()) return;
state.details = (await http.get(`/api/gist/source/${res.data.source.gistId}`)).data;
},
@@ -1076,17 +1118,19 @@ export const anonymizeController = function (state, http, html, params, location
}
});
http.get("/github/connections").then(res => { state.githubConnections = res.data; }).catch(() => {});
// URL change handler - auto-detect type
state.urlSelected = async () => {
state.terms = state.defaultTerms;
if (!state.isUpdate) {
state.urlSelected = async (preserveDraft = false) => {
if (!preserveDraft) state.terms = state.defaultTerms;
if (!preserveDraft && !state.isUpdate) {
state.repoId = "";
state.pullRequestId = "";
state.gistId = "";
}
state.details = null;
state.branches = [];
state.source = { type: "GitHubStream", branch: "", commit: "" };
if (!preserveDraft) state.source = { type: "GitHubStream", branch: "", commit: "" };
state.anonymize_readme = "";
state.readme = "";
state.html_readme = "";
@@ -1136,7 +1180,7 @@ export const anonymizeController = function (state, http, html, params, location
state.isUpdate &&
state._originalBranch === state.source.branch &&
!!state.source.commit;
if (!keepSavedCommit) {
if (!keepSavedCommit && !(state._preservingDraft && state.source.commit)) {
state.source.commit = selected.commit;
}
state.readme = selected.readme;
@@ -1149,7 +1193,7 @@ export const anonymizeController = function (state, http, html, params, location
const o = parseGithubUrl(state.sourceUrl);
try {
const branches = await http.get(`/api/repo/${o.owner}/${o.repo}/branches`, {
params: { force: force === true ? "1" : "0", repositoryID: sourceRepositoryID() },
params: { anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, force: force === true ? "1" : "0", repositoryID: sourceRepositoryID() },
});
state.branches = branches.data;
state.sourceUnreachable = false;
@@ -1167,7 +1211,7 @@ export const anonymizeController = function (state, http, html, params, location
!state.options.update &&
state._originalBranch === state.source.branch &&
!!state.source.commit;
if (!keepSavedCommit) {
if (!keepSavedCommit && !(state._preservingDraft && state.source.commit)) {
state.source.commit = selected[0].commit;
}
state.readme = selected[0].readme;
@@ -1197,7 +1241,7 @@ export const anonymizeController = function (state, http, html, params, location
// #364) are reflected without waiting for the cached metadata to
// expire. The endpoint hits the GitHub API once.
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/`, {
params: { repositoryID: sourceRepositoryID(), force: "1" },
params: { anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, repositoryID: sourceRepositoryID(), force: "1" },
});
state.details = res.data;
if (state.details && state.details.id) {
@@ -1225,7 +1269,7 @@ export const anonymizeController = function (state, http, html, params, location
const o = parseGithubUrl(state.sourceUrl);
try {
const res = await http.get(`/api/repo/${o.owner}/${o.repo}/readme`, {
params: { force: force === true ? "1" : "0", branch: state.source.branch, repositoryID: sourceRepositoryID() },
params: { anonymizedRepoId: state.isUpdate && params.repoId && parseRepoFullName(state.sourceUrl) === state._originalFullName ? params.repoId : undefined, connection: state.githubConnection, force: force === true ? "1" : "0", branch: state.source.branch, repositoryID: sourceRepositoryID() },
});
state.readme = res.data;
} catch (error) {
@@ -1322,7 +1366,7 @@ export const anonymizeController = function (state, http, html, params, location
const o = parseGithubUrl(state.sourceUrl);
try {
resetValidity();
const res = await http.get(`/api/pr/${o.owner}/${o.repo}/${o.pullRequestId}`);
const res = await http.get(`/api/pr/${o.owner}/${o.repo}/${o.pullRequestId}`, { params: { connection: state.githubConnection } });
state.details = res.data;
if (!state.pullRequestId) {
state.pullRequestId = o.repo + "-PR" + o.pullRequestId + "-" + generateRandomId(4);
@@ -1640,6 +1684,7 @@ export const anonymizeController = function (state, http, html, params, location
const payload = {
repoId: state.repoId,
terms: state.terms.trim().split("\n").filter((f) => f),
connection: state.githubConnection,
fullName: `${o.owner}/${o.repo}`,
repository: state.sourceUrl,
options: state.options,
@@ -1692,6 +1737,7 @@ export const anonymizeController = function (state, http, html, params, location
const o = parseGithubUrl(state.sourceUrl);
const payload = {
pullRequestId: state.pullRequestId,
connection: state.githubConnection,
terms: state.terms.trim().split("\n").filter((f) => f),
source: { repositoryFullName: `${o.owner}/${o.repo}`, pullRequestId: o.pullRequestId },
options: state.options,
@@ -2749,3 +2795,33 @@ export const conferenceController = function (state, http, location, params) {
}
getConference();
};
export const connectionsController = function (state, http) {
state.connections = null;
state.connectionError = "";
state.busy = false;
state.loadConnections = () => http.get("/github/connections").then(res => {
state.connections = res.data;
}).catch(error => { state.connectionError = error.data?.error || "Unable to load connections."; });
state.changeConnection = async (resource, connection, preview) => {
state.busy = true;
state.connectionError = "";
try {
const result = await http.post("/github/connections/migrate", { type: resource.type, id: resource.id, connection, preview },
{ headers: { "X-CSRF-Token": state.connections.csrf } });
if (preview) resource.eligible = result.data.eligible;
else await state.loadConnections();
} catch (error) { state.connectionError = error.data?.error || "Unable to change connection."; }
finally { state.busy = false; }
};
state.disconnectOAuth = async () => {
state.busy = true;
try {
await http.post("/github/connections/disconnect-oauth", {}, { headers: { "X-CSRF-Token": state.connections.csrf } });
await state.loadConnections();
} catch (error) { state.connectionError = error.data?.error || "Unable to disconnect OAuth."; }
finally { state.busy = false; }
};
state.loadConnections();
};
+2
View File
@@ -2,6 +2,8 @@ import * as pages from "./app.js";
import * as admin from "./admin.js";
export const pageRoutes = [
{path: "/connections", template: "partials/connections.htm", title: "GitHub connections Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.connectionsController(state, services.http)},
{path: "/signin", template: "partials/signin.htm", title: "Sign in Anonymous GitHub", preserveExplorer: false, setup: () => {}},
{path: "/", template: "partials/home.htm", title: "Anonymous GitHub Share the code, not the author", preserveExplorer: false, setup: (state, services) => pages.homeController(state, services.http, services.location, services.window, services.timeout)},
{path: "/dashboard", template: "partials/dashboard.htm", title: "Your anonymizations Anonymous GitHub", preserveExplorer: false, setup: (state, services) => pages.unifiedDashboardController(state, services.http, services.location, services.promises, services.window, services.quotaService)},
{"path":"/pr-dashboard","redirect":"/dashboard"},
+4
View File
@@ -1,3 +1,5 @@
import { render as connectionsTemplate } from "../partials/connections.htm";
import { render as signinTemplate } from "../partials/signin.htm";
import { render as template0 } from "../partials/404.htm";
import { render as template1 } from "../partials/admin/conferences.htm";
import { render as template2 } from "../partials/admin/errors.htm";
@@ -25,6 +27,8 @@ import { render as template23 } from "../partials/profile.htm";
import { render as template24 } from "../partials/pullRequest.htm";
import { render as template25 } from "../partials/status.htm";
export const templates = {
"partials/connections.htm": connectionsTemplate,
"partials/signin.htm": signinTemplate,
"partials/404.htm": template0,
"partials/admin/conferences.htm": template1,
"partials/admin/errors.htm": template2,
+20 -20
View File
File diff suppressed because one or more lines are too long
+32 -2
View File
@@ -2,6 +2,18 @@ import { resolve } from "path";
import { randomBytes } from "crypto";
interface Config {
GITHUB_APP_ENABLED: boolean;
GITHUB_APP_NEW_CONNECTIONS: boolean;
GITHUB_OAUTH_ENABLED: boolean;
GITHUB_APP_ID: string;
GITHUB_APP_SLUG: string;
GITHUB_APP_CLIENT_ID: string;
GITHUB_APP_CLIENT_SECRET: string;
GITHUB_APP_PRIVATE_KEY: string;
GITHUB_APP_PRIVATE_KEY_FILE: string;
GITHUB_APP_CALLBACK: string;
GITHUB_APP_WEBHOOK_SECRET: string;
CREDENTIAL_KEYS: string;
CREDENTIAL_ACTIVE_KEY_ID: string;
CREDENTIAL_LEGACY_READS: boolean;
@@ -55,6 +67,18 @@ interface Config {
RATE_LIMIT: number;
}
const config: Config = {
GITHUB_APP_ENABLED: false,
GITHUB_APP_NEW_CONNECTIONS: true,
GITHUB_OAUTH_ENABLED: true,
GITHUB_APP_ID: "",
GITHUB_APP_SLUG: "",
GITHUB_APP_CLIENT_ID: "",
GITHUB_APP_CLIENT_SECRET: "",
GITHUB_APP_PRIVATE_KEY: "",
GITHUB_APP_PRIVATE_KEY_FILE: "",
GITHUB_APP_CALLBACK: "http://localhost:5000/github/app/callback",
GITHUB_APP_WEBHOOK_SECRET: "",
// Predictable defaults are dangerous: a known SESSION_SECRET lets anyone
// forge session cookies. Default to empty and resolve below — random in
// dev, required in production. See the post-env block.
@@ -152,10 +176,10 @@ if (!config.SESSION_SECRET || config.SESSION_SECRET === "SESSION_SECRET") {
// Refuse to start in production with the placeholder OAuth credentials or the
// default database password baked into the image.
if (isProduction) {
const insecureDefaults: [string, string][] = [
const insecureDefaults: [string, string][] = config.GITHUB_OAUTH_ENABLED ? [
["CLIENT_ID", "CLIENT_ID"],
["CLIENT_SECRET", "CLIENT_SECRET"],
];
] : [];
if (!config.MONGODB_URI) {
insecureDefaults.push(["DB_PASSWORD", "password"]);
}
@@ -168,4 +192,10 @@ if (isProduction) {
}
}
if (config.GITHUB_APP_ENABLED) {
for (const name of ["GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_APP_CALLBACK", "GITHUB_APP_WEBHOOK_SECRET"] as const) {
if (!config[name]) throw new Error(`${name} is required when GITHUB_APP_ENABLED=true`);
}
if (!config.GITHUB_APP_PRIVATE_KEY && !config.GITHUB_APP_PRIVATE_KEY_FILE) throw new Error("A GitHub App private key is required");
}
export default config;
+5
View File
@@ -1,3 +1,5 @@
import { APP_PROVIDER, appError } from "./github-app";
import CredentialModel from "./model/credentials/credentials.model";
import { getCredentialToken } from "./credentials";
import { RepositoryStatus } from "./types";
import User from "./User";
@@ -47,6 +49,9 @@ export default class Gist {
}
async getToken() {
if (config.GITHUB_APP_ENABLED && !(await getCredentialToken(this.owner.id)) && await CredentialModel.exists({ ownerId: this.owner.id, provider: APP_PROVIDER })) {
throw appError("github_oauth_required");
}
return (await getCredentialToken(this.owner.id, "github", { collection: "anonymizedgists", id: this._model._id })) || config.GITHUB_TOKEN;
}
+35 -5
View File
@@ -1,3 +1,7 @@
import AnonymizedRepositoryModel from "./model/anonymizedRepositories/anonymizedRepositories.model";
import { isConnected } from "../server/database";
import { githubQuotaKey, githubTokenContext } from "./github-token-context";
import { boundAppToken } from "./github-app";
import { Octokit } from "@octokit/rest";
import { throttling } from "@octokit/plugin-throttling";
import { createClient, RedisClientType } from "redis";
@@ -63,7 +67,7 @@ const ThrottledOctokit = Octokit.plugin(throttling);
const tokenGates = new Map<string, { resetAt: number }>();
function setTokenGate(token: string, retryAfterSec: number) {
const key = token.slice(-8);
const key = githubQuotaKey(token);
const resetAt = Date.now() + retryAfterSec * 1000;
const existing = tokenGates.get(key);
if (!existing || resetAt > existing.resetAt) {
@@ -95,7 +99,7 @@ export class RateLimitDelayError extends Error {
* Returns the reset timestamp, or 0 if no gate is active.
*/
export function getTokenGateResetAt(token: string): number {
const key = token.slice(-8);
const key = githubQuotaKey(token);
const gate = tokenGates.get(key);
if (!gate) return 0;
if (gate.resetAt <= Date.now()) {
@@ -106,7 +110,7 @@ export function getTokenGateResetAt(token: string): number {
}
async function waitForTokenGate(token: string): Promise<void> {
const key = token.slice(-8);
const key = githubQuotaKey(token);
const localGate = tokenGates.get(key);
let waitMs = 0;
let resetAt = 0;
@@ -208,8 +212,11 @@ export async function getRedisGateResetAt(tokenKey: string): Promise<number> {
}
export function octokit(token: string) {
const context = githubTokenContext(token);
const oct = new ThrottledOctokit({
auth: token,
// Managed App tokens are supplied by the renewal hook. Octokit's static
// token strategy would otherwise overwrite the renewed Authorization header.
auth: context ? undefined : token,
request: {
fetch: fetch,
},
@@ -240,6 +247,19 @@ export function octokit(token: string) {
},
},
});
if (context) {
oct.hook.before("request", async options => {
options.headers.authorization = `token ${await context.renew()}`;
});
oct.hook.wrap("request", async (request, options) => {
try { return await request(options); }
catch (error) {
if ((error as { status?: number }).status !== 401) throw error;
options.headers.authorization = `token ${await context.renew(true)}`;
return request(options);
}
});
}
oct.hook.error("request", (err) => {
if (isGitHubRateLimitError(err)) {
throw new AnonymousError("github_rate_limit_exceeded", {
@@ -258,7 +278,8 @@ export { waitForTokenGate };
export async function checkToken(token: string) {
const oct = octokit(token);
try {
await oct.users.getAuthenticated();
if (token.startsWith("ghs_")) await oct.request("GET /installation/repositories");
else await oct.users.getAuthenticated();
return true;
} catch (err) {
if (
@@ -276,6 +297,15 @@ const checkedRepositoryTokens = new WeakMap<Repository, string>();
export async function getToken(repository: Repository) {
repository.assertNotArchived();
logger.debug("getToken", { repoId: repository.repoId });
if (isConnected && !repository.model.isNew) {
const current = await AnonymizedRepositoryModel.findById(repository.model._id).select("owner githubAccess").lean();
if (!current || String(current.owner) !== repository.owner.id || current.githubAccess?.revision !== repository.model.githubAccess?.revision) {
throw new AnonymousError("connection_changed", { httpStatus: 409 });
}
}
if (repository.model.githubAccess?.kind === "github-app") {
return boundAppToken(repository.owner.id, repository.model.githubAccess);
}
const credential = await getCredential(repository.owner.id);
const ownerAccessToken = credential?.token;
if (ownerAccessToken) {
+2
View File
@@ -1,3 +1,4 @@
import { boundAppToken } from "./github-app";
import { getCredentialToken } from "./credentials";
import { RepositoryStatus } from "./types";
import User from "./User";
@@ -25,6 +26,7 @@ export default class PullRequest {
}
async getToken() {
if (this._model.githubAccess?.kind === "github-app") return boundAppToken(this.owner.id, this._model.githubAccess);
return (await getCredentialToken(this.owner.id, "github", { collection: "anonymizedpullrequests", id: this._model._id })) || config.GITHUB_TOKEN;
}
+14 -2
View File
@@ -483,6 +483,7 @@ export default class Repository {
status: { $nin: [RepositoryStatus.ARCHIVED, RepositoryStatus.REMOVING, RepositoryStatus.REMOVED,
RepositoryStatus.EXPIRING, RepositoryStatus.EXPIRED] },
anonymizeDate: this._model.anonymizeDate,
"githubAccess.revision": this._model.githubAccess?.revision || { $exists: false },
} : {}),
},
{ $set: { status, statusDate, statusMessage } }
@@ -508,8 +509,19 @@ export default class Repository {
/**
* Remove the repository
*/
async remove() {
await this.updateStatus(RepositoryStatus.REMOVING);
async remove(expected?: { accessRevision?: string }) {
if (expected) {
// Claim the lifecycle before deleting files; migration rejects REMOVING.
this.assertNotArchived();
const result = await AnonymizedRepositoryModel.updateOne({ _id: this.model._id,
status: this.model.status,
"githubAccess.revision": expected.accessRevision || { $exists: false },
}, { $set: { status: RepositoryStatus.REMOVING, statusDate: new Date() } });
if (!result.matchedCount) throw new AnonymousError("connection_changed", { httpStatus: 409 });
this.model.status = RepositoryStatus.REMOVING;
} else {
await this.updateStatus(RepositoryStatus.REMOVING);
}
await this.resetSate();
await this.updateStatus(RepositoryStatus.REMOVED);
}
+19 -1
View File
@@ -1,3 +1,6 @@
import config from "../config";
import { GitHubRepositoryInfo, APP_PROVIDER, appRepositories, appUserToken } from "./github-app";
import CredentialModel from "./model/credentials/credentials.model";
import { getCredentialToken } from "./credentials";
import AnonymizedRepositoryModel from "./model/anonymizedRepositories/anonymizedRepositories.model";
import RepositoryModel from "./model/repositories/repositories.model";
@@ -33,7 +36,9 @@ export default class User {
}
async getAccessToken(): Promise<string> {
return getCredentialToken(this.id);
const oauth = await getCredentialToken(this.id);
if (oauth || !config.GITHUB_APP_ENABLED) return oauth;
return appUserToken(this.id);
}
get photo(): string | undefined {
@@ -59,6 +64,19 @@ export default class User {
*/
force: boolean;
}): Promise<GitHubRepository[]> {
if (config.GITHUB_APP_ENABLED && await CredentialModel.exists({ ownerId: this.id, provider: APP_PROVIDER })) {
const oauth = await getCredentialToken(this.id);
let appRepos: GitHubRepositoryInfo[] = [];
try { appRepos = await appRepositories(this.id); }
catch (error) { if (!oauth) throw error; }
// Discovery may list the independently connected OAuth provider when the
// App is unavailable. Resource access never falls back between providers.
const legacy = oauth ? await octokit(oauth).paginate("GET /user/repos", { visibility: "all", per_page: 100 }) : [];
const repos = new Map<number, GitHubRepositoryInfo>(legacy.map(r => [r.id, r]));
for (const r of appRepos) repos.set(r.id, r);
return [...repos.values()].map(r => new GitHubRepository(new RepositoryModel({ externalId: "gh_" + r.id,
name: r.full_name, url: r.html_url, size: r.size, defaultBranch: r.default_branch })));
}
if (
!this._model.repositories ||
this._model.repositories.length == 0 ||
+6 -6
View File
@@ -28,19 +28,19 @@ export function createTokenCipher(rawKeys: string, activeKeyId: string) {
keys.set(id, Buffer.from(value, "base64"));
}
if (!keys.has(activeKeyId)) throw new Error("CREDENTIAL_ACTIVE_KEY_ID is missing from CREDENTIAL_KEYS");
const aad = (ownerId: string, provider: string) =>
Buffer.from(JSON.stringify(["credentials", ownerId, provider, "encryptedToken", 1]));
const aad = (ownerId: string, provider: string, purpose = "encryptedToken") =>
Buffer.from(JSON.stringify(["credentials", ownerId, provider, purpose, 1]));
return {
encrypt(token: string, ownerId: string, provider: string): EncryptedToken {
encrypt(token: string, ownerId: string, provider: string, purpose = "encryptedToken"): EncryptedToken {
if (!token) throw new Error("Cannot encrypt an empty credential");
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", keys.get(activeKeyId)!, nonce);
cipher.setAAD(aad(ownerId, provider));
cipher.setAAD(aad(ownerId, provider, purpose));
const ciphertext = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
return { version: 1, keyId: activeKeyId, nonce: nonce.toString("base64"),
ciphertext: ciphertext.toString("base64"), tag: cipher.getAuthTag().toString("base64") };
},
decrypt(value: EncryptedToken, ownerId: string, provider: string): string {
decrypt(value: EncryptedToken, ownerId: string, provider: string, purpose = "encryptedToken"): string {
try {
if (!value || value.version !== 1 || !keys.has(value.keyId)) throw new Error();
const decode = (s: string) => {
@@ -53,7 +53,7 @@ export function createTokenCipher(rawKeys: string, activeKeyId: string) {
const tag = decode(value.tag);
if (nonce.length !== 12 || tag.length !== 16) throw new Error();
const decipher = createDecipheriv("aes-256-gcm", keys.get(value.keyId)!, nonce, { authTagLength: 16 });
decipher.setAAD(aad(ownerId, provider));
decipher.setAAD(aad(ownerId, provider, purpose));
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(decode(value.ciphertext)), decipher.final()]).toString("utf8");
} catch {
+1 -1
View File
@@ -28,7 +28,7 @@ export async function getCredentialToken(ownerId: string, provider = "github", r
}): Promise<string> {
const credential = await getCredential(ownerId, provider);
if (credential) return credential.token;
if (config.CREDENTIAL_LEGACY_READS && resource) {
if (config.CREDENTIAL_LEGACY_READS && provider === "github" && resource) {
const row = await CredentialModel.db.collection(resource.collection).findOne({
_id: resource.id as Types.ObjectId,
owner: new Types.ObjectId(ownerId),
+258
View File
@@ -0,0 +1,258 @@
import { registerGitHubToken } from "./github-token-context";
import { createSign, randomUUID } from "crypto";
import { readFileSync } from "fs";
import config from "../config";
import AnonymousError from "./AnonymousError";
import CredentialModel from "./model/credentials/credentials.model";
import InstallationModel from "./model/github-installation";
import UserModel from "./model/users/users.model";
import { credentialCipher, getCredentialToken } from "./credentials";
import { RepositoryAccess } from "./repository-access.types";
export const APP_PROVIDER = "github-app-user";
export function appError(code = "github_app_reconnect_required", status = 403) {
return new AnonymousError(code, { httpStatus: status });
}
// Never expose upstream bodies, bearer credentials or signed URLs in errors.
export async function githubRequest<T>(path: string, token: string, method = "GET", body?: unknown): Promise<T> {
if (!path.startsWith("/") || path.startsWith("//")) throw appError("invalid_github_path", 400);
let response: Response;
try {
response = await fetch(`https://api.github.com${path}`, {
method, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(20000),
});
} catch { throw appError("github_unavailable", 502); }
if (!response.ok) {
const limited = response.status === 429 || (response.status === 403 &&
(response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after")));
throw appError(limited ? "github_rate_limit_exceeded" : response.status >= 500 ? "github_unavailable" :
response.status === 401 ? "github_app_reconnect_required" : "github_app_access_required",
limited ? 429 : response.status >= 500 ? 502 : 403);
}
if (response.status === 204) return undefined as T;
return await response.json() as T;
}
export function appJWT(now = Date.now()): string {
if (!config.GITHUB_APP_ENABLED) throw appError("github_app_disabled", 503);
const key = config.GITHUB_APP_PRIVATE_KEY || readFileSync(config.GITHUB_APP_PRIVATE_KEY_FILE, "utf8");
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const payload = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ iat: Math.floor(now / 1000) - 60,
exp: Math.floor(now / 1000) + 540, iss: config.GITHUB_APP_CLIENT_ID })}`;
return `${payload}.${createSign("RSA-SHA256").update(payload).sign(key, "base64url")}`;
}
export interface AppTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
refresh_token_expires_in: number;
}
export async function exchangeAppToken(values: Record<string, string>): Promise<AppTokenResponse> {
let response: Response;
try {
response = await fetch("https://github.com/login/oauth/access_token", {
method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ ...values, client_id: config.GITHUB_APP_CLIENT_ID, client_secret: config.GITHUB_APP_CLIENT_SECRET }),
signal: AbortSignal.timeout(20000),
});
} catch { throw appError("github_unavailable", 502); }
if (response.status >= 500) throw appError("github_unavailable", 502);
if (response.status === 429) throw appError("github_rate_limit_exceeded", 429);
const data = await response.json().catch(() => null) as AppTokenResponse | null;
if (!response.ok || !data || typeof data.access_token !== "string" || !data.access_token ||
typeof data.refresh_token !== "string" || !data.refresh_token ||
!Number.isFinite(data.expires_in) || data.expires_in <= 0 ||
!Number.isFinite(data.refresh_token_expires_in) || data.refresh_token_expires_in <= 0) {
throw appError("github_app_reconnect_required", 401);
}
return data;
}
function tokenFields(ownerId: string, data: AppTokenResponse) {
const cipher = credentialCipher();
return { encryptedToken: cipher.encrypt(data.access_token, ownerId, APP_PROVIDER),
encryptedRefreshToken: cipher.encrypt(data.refresh_token, ownerId, APP_PROVIDER, "encryptedRefreshToken"),
expiresAt: new Date(Date.now() + data.expires_in * 1000),
refreshExpiresAt: new Date(Date.now() + data.refresh_token_expires_in * 1000),
revision: randomUUID(), revoked: false, updatedAt: new Date() };
}
export async function saveAppGrant(ownerId: string, data: AppTokenResponse) {
await CredentialModel.updateOne({ ownerId, provider: APP_PROVIDER }, {
$set: tokenFields(ownerId, data), $unset: { refreshLock: "", refreshLockUntil: "" },
}, { upsert: true, runValidators: true });
await UserModel.updateOne({ _id: ownerId }, { $set: { repositories: [] } });
}
export async function appUserToken(ownerId: string): Promise<string> {
if (!config.GITHUB_APP_ENABLED) throw appError("github_app_disabled", 503);
const user = await UserModel.findById(ownerId).select("status externalIDs").lean();
if (!user || user.status === "removed" || user.status === "banned") throw appError();
// MongoDB lock serializes refresh across API processes and streamers. Conditional
// writes cannot replace a newer login or revive a grant revoked during refresh.
for (let attempt = 0; attempt < 30; attempt++) {
const row = await CredentialModel.findOne({ ownerId, provider: APP_PROVIDER })
.select("+encryptedToken +encryptedRefreshToken").lean();
if (!row || row.revoked) throw appError();
if (row.expiresAt && row.expiresAt.getTime() > Date.now() + 60000) {
return credentialCipher().decrypt(row.encryptedToken, ownerId, APP_PROVIDER);
}
if (!row.encryptedRefreshToken || !row.refreshExpiresAt || row.refreshExpiresAt.getTime() <= Date.now()) throw appError();
const lock = randomUUID();
const acquired = await CredentialModel.updateOne({ _id: row._id, revision: row.revision, revoked: { $ne: true },
$or: [{ refreshLockUntil: { $exists: false } }, { refreshLockUntil: { $lt: new Date() } }] },
{ $set: { refreshLock: lock, refreshLockUntil: new Date(Date.now() + 30000) } });
if (!acquired.modifiedCount) { await new Promise(resolve => setTimeout(resolve, 1000)); continue; }
try {
const refreshed = await exchangeAppToken({ grant_type: "refresh_token",
refresh_token: credentialCipher().decrypt(row.encryptedRefreshToken, ownerId, APP_PROVIDER, "encryptedRefreshToken") });
const saved = await CredentialModel.updateOne({ _id: row._id, revision: row.revision, refreshLock: lock, revoked: { $ne: true } },
{ $set: tokenFields(ownerId, refreshed), $unset: { refreshLock: "", refreshLockUntil: "" } });
if (saved.modifiedCount) return refreshed.access_token;
} finally {
await CredentialModel.updateOne({ _id: row._id, refreshLock: lock }, { $unset: { refreshLock: "", refreshLockUntil: "" } });
}
}
throw appError("github_app_refresh_busy", 503);
}
// Replayed revocations must never invalidate a newer, working authorization.
export async function reconcileAppGrant(ownerId: string) {
const row = await CredentialModel.findOne({ ownerId, provider: APP_PROVIDER }).lean();
if (!row || row.revoked) return;
try {
const token = await appUserToken(ownerId);
await githubRequest("/user", token);
} catch (error) {
if (!(error instanceof Error) || error.message !== "github_app_reconnect_required") throw error;
await CredentialModel.updateOne({ _id: row._id, revision: row.revision },
{ $set: { revoked: true, revision: randomUUID() } });
}
}
// Pending reconciliation is durable and retried on access after upstream failures.
// The revision guard prevents an older response from undoing a newer webhook.
export async function reconcileInstallation(installationId: number, revision: string) {
const current = await githubRequest<AppInstallation>(`/app/installations/${installationId}`, appJWT());
if (String(current.app_id) !== config.GITHUB_APP_ID) throw appError();
await InstallationModel.updateOne({ appId: config.GITHUB_APP_ID, installationId, revision },
{ $set: { blocked: !!current.suspended_at, reconciliationPending: false,
accountId: current.account.id, accountLogin: current.account.login, accountType: current.account.type,
checkedAt: new Date(), revision: randomUUID() } });
}
export interface GitHubRepositoryInfo {
id: number; full_name: string; name: string; private: boolean; html_url: string; size: number;
default_branch: string; owner: { id: number; login: string };
}
export interface AppInstallation {
id: number; app_id: number; suspended_at: string | null;
account: { id: number; login: string; type: string };
permissions: Record<string, string>;
}
export async function userInstallations(ownerId: string): Promise<AppInstallation[]> {
const token = await appUserToken(ownerId);
const result: AppInstallation[] = [];
for (let page = 1; ; page++) {
const data = await githubRequest<{ installations: AppInstallation[] }>(`/user/installations?per_page=100&page=${page}`, token);
result.push(...data.installations.filter(i => String(i.app_id) === config.GITHUB_APP_ID));
if (data.installations.length < 100) break;
}
return result;
}
export async function appRepositories(ownerId: string) {
const installations = await userInstallations(ownerId);
const token = await appUserToken(ownerId);
const results: (GitHubRepositoryInfo & { installationId: number })[] = [];
for (const installation of installations) {
if (installation.suspended_at) continue;
for (let page = 1; ; page++) {
const data = await githubRequest<{ repositories: GitHubRepositoryInfo[] }>(
`/user/installations/${installation.id}/repositories?per_page=100&page=${page}`, token);
results.push(...data.repositories.map(r => ({ ...r, installationId: installation.id })));
if (data.repositories.length < 100) break;
}
}
return results;
}
const installationTokens = new Map<string, { token: string; expires: number }>();
const minting = new Map<string, Promise<string>>();
export function clearAppTokenCache() { installationTokens.clear(); }
async function installationToken(binding: RepositoryAccess, ownerId: string): Promise<string> {
const id = binding.installationId;
let local = await InstallationModel.findOne({ appId: config.GITHUB_APP_ID, installationId: id }).lean();
if (local?.reconciliationPending && local.revision) {
await reconcileInstallation(id!, local.revision);
local = await InstallationModel.findOne({ appId: config.GITHUB_APP_ID, installationId: id }).lean();
}
if (local?.blocked) throw appError("github_app_access_required");
const key = `${ownerId}:${id}:${binding.repositoryId}:${local?.revision || ""}`;
const cached = installationTokens.get(key);
if (cached && cached.expires > Date.now() + 60000) return cached.token;
if (minting.has(key)) return minting.get(key)!;
const work = (async () => {
const jwt = appJWT();
const installation = await githubRequest<AppInstallation>(`/app/installations/${id}`, jwt);
if (String(installation.app_id) !== config.GITHUB_APP_ID || installation.suspended_at || installation.permissions.contents !== "read") {
throw appError("github_app_access_required");
}
// Refuse accidentally configured write permissions instead of presenting a
// misleading read-only connection to the user.
if (Object.values(installation.permissions).some(p => p === "write" || p === "admin")) throw appError("github_app_permissions_invalid");
const permissions: Record<string, string> = { contents: "read", metadata: "read" };
for (const p of ["pull_requests", "pages"]) if (installation.permissions[p] === "read") permissions[p] = "read";
const issued = await githubRequest<{ token: string; expires_at: string }>(`/app/installations/${id}/access_tokens`, jwt, "POST",
{ repository_ids: [binding.repositoryId], permissions });
const expires = Date.parse(issued.expires_at);
if (!issued.token || !Number.isFinite(expires)) throw appError();
// Bound memory and ensure a revocation that races minting is observed before use.
if (installationTokens.size >= 1000) installationTokens.clear();
const current = await InstallationModel.findOne({ appId: config.GITHUB_APP_ID, installationId: id }).lean();
if (current?.blocked || current?.revision !== local?.revision) throw appError("github_app_access_required");
installationTokens.set(key, { token: issued.token, expires });
return issued.token;
})();
minting.set(key, work);
try { return await work; } finally { minting.delete(key); }
}
export async function boundAppToken(ownerId: string, binding: RepositoryAccess): Promise<string> {
if (!Number.isSafeInteger(binding.repositoryId) || !Number.isSafeInteger(binding.installationId)) throw appError();
const userToken = await appUserToken(ownerId);
// User token checks the intersection of user and App rights on every access.
// No indefinite local authorization cache can preserve a departed user's access.
await githubRequest<GitHubRepositoryInfo>(`/repositories/${binding.repositoryId}`, userToken);
const token = await installationToken(binding, ownerId);
registerGitHubToken(token, { quotaKey: `installation:${binding.installationId}`, renew: async (force) => {
if (force) clearAppTokenCache();
return boundAppToken(ownerId, binding);
} });
return token;
}
export async function selectRepositoryAccess(ownerId: string, fullName: string, choice?: unknown): Promise<{ token: string; binding: RepositoryAccess }> {
if (!/^[^/\s]+\/[^/\s]+$/.test(fullName)) throw appError("repo_not_found", 400);
if (choice !== undefined && choice !== "oauth" && choice !== "github-app") throw appError("invalid_connection", 400);
const hasApp = config.GITHUB_APP_ENABLED && await CredentialModel.exists({ ownerId, provider: APP_PROVIDER });
if (choice === "github-app" || (choice === undefined && hasApp)) {
const repo = (await appRepositories(ownerId)).find(r => r.full_name.toLowerCase() === fullName.toLowerCase());
if (!repo) throw appError("github_app_access_required");
const binding: RepositoryAccess = { kind: "github-app", repositoryId: repo.id, installationId: repo.installationId, revision: randomUUID() };
return { binding, token: await boundAppToken(ownerId, binding) };
}
const token = await getCredentialToken(ownerId);
if (!token) throw appError("github_oauth_required");
return { token, binding: { kind: "oauth", revision: randomUUID() } };
}
export function installationURL(targetId?: number, repositoryIds: number[] = []) {
const base = `https://github.com/apps/${encodeURIComponent(config.GITHUB_APP_SLUG)}/installations/new`;
if (!targetId || !repositoryIds.length) return base;
const url = new URL(`${base}/permissions`);
url.searchParams.set("suggested_target_id", String(targetId));
for (const id of repositoryIds.slice(0, 100)) url.searchParams.append("repository_ids[]", String(id));
return url.toString();
}
+12
View File
@@ -0,0 +1,12 @@
import { createHash } from "crypto";
interface TokenContext { quotaKey: string; renew: (force?: boolean) => Promise<string>; }
const contexts = new Map<string, TokenContext>();
export function registerGitHubToken(token: string, context: TokenContext) {
if (contexts.size >= 2000 && !contexts.has(token)) contexts.delete(contexts.keys().next().value!);
contexts.set(token, context);
}
export function githubTokenContext(token: string) { return contexts.get(token); }
export function githubQuotaKey(token: string) {
return contexts.get(token)?.quotaKey || createHash("sha256").update(token).digest("hex").slice(0, 24);
}
+1
View File
@@ -145,6 +145,7 @@ export async function verifyCredentials(db: mongo.Db, cipher: Cipher) {
let checked = 0;
for await (const row of db.collection("credentials").find({})) {
cipher.decrypt(row.encryptedToken as EncryptedToken, String(row.ownerId), row.provider);
if (row.encryptedRefreshToken) cipher.decrypt(row.encryptedRefreshToken as EncryptedToken, String(row.ownerId), row.provider, "encryptedRefreshToken");
if (!(await db.collection("users").findOne({ _id: row.ownerId, status: { $ne: "removed" } }))) {
throw new Error("Credential has no active owner");
}
@@ -1,3 +1,4 @@
import { repositoryAccessSchema } from "../repository-access.schema";
import { Schema } from "mongoose";
const AnonymizedPullRequestSchema = new Schema({
@@ -15,6 +16,7 @@ const AnonymizedPullRequestSchema = new Schema({
lastView: Date,
pageView: Number,
owner: { type: Schema.Types.ObjectId, index: true },
githubAccess: { type: repositoryAccessSchema, default: undefined },
conference: String,
source: {
pullRequestId: Number,
@@ -1,3 +1,4 @@
import { RepositoryAccess } from "../../repository-access.types";
import { Document, Model } from "mongoose";
import { RepositoryStatus } from "../../types";
@@ -13,6 +14,7 @@ export interface IAnonymizedPullRequest {
accessToken?: string;
};
owner: string;
githubAccess?: RepositoryAccess;
conference: string;
options: {
terms: string[];
@@ -1,3 +1,4 @@
import { repositoryAccessSchema } from "../repository-access.schema";
import { Schema } from "mongoose";
const AnonymizedRepositorySchema = new Schema({
@@ -31,6 +32,7 @@ const AnonymizedRepositorySchema = new Schema({
addedAt: { type: Date, default: Date.now },
},
],
githubAccess: { type: repositoryAccessSchema, default: undefined },
conference: String,
source: {
type: { type: String },
@@ -1,3 +1,4 @@
import { RepositoryAccess } from "../../repository-access.types";
import { Document, Model } from "mongoose";
import { RepositoryStatus } from "../../types";
@@ -20,6 +21,7 @@ export interface IAnonymizedRepository {
accessToken?: string;
};
owner: string;
githubAccess?: RepositoryAccess;
coauthors?: {
username: string;
githubId?: string;
@@ -6,6 +6,13 @@ export interface ICredential {
provider: string;
encryptedToken: EncryptedToken;
updatedAt: Date;
encryptedRefreshToken?: EncryptedToken;
expiresAt?: Date;
refreshExpiresAt?: Date;
refreshLock?: string;
refreshLockUntil?: Date;
revision?: string;
revoked?: boolean;
}
const envelope = new Schema({
version: { type: Number, required: true, enum: [1] },
@@ -16,9 +23,16 @@ const envelope = new Schema({
}, { _id: false });
const schema = new Schema<ICredential>({
ownerId: { type: Schema.Types.ObjectId, required: true, ref: "user" },
provider: { type: String, required: true, enum: ["github"] },
provider: { type: String, required: true, enum: ["github", "github-app-user"] },
encryptedToken: { type: envelope, required: true, select: false },
updatedAt: { type: Date, required: true },
encryptedRefreshToken: { type: envelope, select: false },
expiresAt: Date,
refreshExpiresAt: Date,
refreshLock: { type: String, select: false },
refreshLockUntil: Date,
revision: String,
revoked: Boolean,
}, { collection: "credentials" });
schema.index({ ownerId: 1, provider: 1 }, { unique: true });
export default model<ICredential>("Credential", schema);
+15
View File
@@ -0,0 +1,15 @@
import { model, Schema } from "mongoose";
const schema = new Schema({
appId: { type: String, required: true },
installationId: { type: Number, required: true },
accountId: Number,
accountLogin: String,
accountType: String,
blocked: { type: Boolean, default: false },
reconciliationPending: { type: Boolean, default: false },
checkedAt: Date,
revision: String,
});
schema.index({ appId: 1, installationId: 1 }, { unique: true });
export default model("GitHubInstallation", schema);
@@ -0,0 +1,8 @@
import { Schema } from "mongoose";
export const repositoryAccessSchema = new Schema({
kind: { type: String, enum: ["oauth", "github-app"], required: true },
repositoryId: Number,
installationId: Number,
revision: { type: String, required: true },
}, { _id: false });
+2 -2
View File
@@ -1,8 +1,8 @@
const sensitive = /^(?:authorization|proxy-authorization|cookie|set-cookie|token|access_?tokens?|refresh_?token|encryptedToken|ciphertext|nonce|tag|password|client_?secret|CREDENTIAL_KEYS)$/i;
const sensitive = /^(?:authorization|proxy-authorization|cookie|set-cookie|token|access_?tokens?|refresh_?token|encryptedToken|encryptedRefreshToken|private_?key|GITHUB_APP_PRIVATE_KEY|GITHUB_APP_CLIENT_SECRET|GITHUB_APP_WEBHOOK_SECRET|ciphertext|nonce|tag|password|client_?secret|CREDENTIAL_KEYS)$/i;
export function redactSecrets(value: unknown, seen = new WeakSet<object>()): unknown {
if (typeof value === "string") return value
.replace(/\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\b/g, "[REDACTED]")
.replace(/((?:access_token|refresh_token|token)=)[^&\s]+/gi, "$1[REDACTED]")
.replace(/((?:access_token|refresh_token|token|code|state)=)[^&\s]+/gi, "$1[REDACTED]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9+/=._-]+/gi, "$1 [REDACTED]");
if (!value || typeof value !== "object" || value instanceof Date) return value;
if (seen.has(value)) return "[Circular]";
+7
View File
@@ -0,0 +1,7 @@
/** A resource connection, never a bearer credential. Missing means legacy OAuth. */
export interface RepositoryAccess {
kind: "oauth" | "github-app";
repositoryId?: number;
installationId?: number;
revision: string;
}
+6 -5
View File
@@ -376,11 +376,12 @@ export async function getRepositoryFromGitHub(opt: {
| RestEndpointMethodTypes["repos"]["getPages"]["response"]["data"]["source"]
| undefined;
if (r.has_pages) {
const ghPageRes = await oct.repos.getPages({
owner: opt.owner,
repo: opt.repo,
});
pageSource = ghPageRes.data.source;
try {
const ghPageRes = await oct.repos.getPages({ owner: opt.owner, repo: opt.repo });
pageSource = ghPageRes.data.source;
} catch (error) {
if (![403, 404].includes((error as { status?: number }).status || 0)) throw error;
}
}
if (!isConnected) {
+8 -2
View File
@@ -1,3 +1,4 @@
import { githubQuotaKey } from "../../core/github-token-context";
import { SandboxedJob } from "bullmq";
import { config } from "dotenv";
config();
@@ -27,8 +28,13 @@ export default async function (job: SandboxedJob<RepoJobData, void>) {
if ([RepositoryStatus.ARCHIVED, RepositoryStatus.REMOVING, RepositoryStatus.REMOVED,
RepositoryStatus.EXPIRING, RepositoryStatus.EXPIRED].some((status) => status === repo.status)) return;
repo.protectLifecycle = true;
const token = await getToken(repo);
const tokenKey = token.slice(-8);
let token: string;
try { token = await getToken(repo); }
catch (error) {
await repo.updateStatus(RepositoryStatus.ERROR, error instanceof Error ? error.message : "github_app_reconnect_required");
throw error;
}
const tokenKey = githubQuotaKey(token);
const gateResetAt = await getRedisGateResetAt(tokenKey);
if (gateResetAt > 0) {
+2
View File
@@ -1,3 +1,4 @@
import InstallationModel from "../core/model/github-installation";
import { credentialCipher } from "../core/credentials";
import CredentialModel from "../core/model/credentials/credentials.model";
import mongoose, { ConnectOptions } from "mongoose";
@@ -31,6 +32,7 @@ export async function connect() {
if (!config.MONGODB_URI) options.authSource = "admin";
await mongoose.connect(getMongoUrl(), options);
await CredentialModel.createIndexes();
if (config.GITHUB_APP_ENABLED) await InstallationModel.createIndexes();
isConnected = true;
return database;
+3
View File
@@ -1,3 +1,4 @@
import { githubAppRouter, githubAppWebhook } from "./routes/github-app";
import { config as dotenv } from "dotenv";
dotenv();
@@ -100,6 +101,7 @@ function indexResponse(req: express.Request, res: express.Response) {
export default async function start() {
const app = express();
app.set("query parser", "extended");
app.use("/github/app/webhook", githubAppWebhook);
app.use(express.json());
// Preserve the empty body used by API validation when no JSON was parsed.
app.use((req, _res, next) => {
@@ -241,6 +243,7 @@ export default async function start() {
next();
});
app.use("/github", rate, speedLimiter, githubAppRouter);
app.use("/github", rate, speedLimiter, connectionRouter);
// api routes
+17 -4
View File
@@ -117,12 +117,14 @@ const verify = async (
}
};
passport.use(
if (config.GITHUB_OAUTH_ENABLED) passport.use(
new Strategy(
{
clientID: config.CLIENT_ID,
clientSecret: config.CLIENT_SECRET,
callbackURL: config.AUTH_CALLBACK,
// passport-oauth2 supports boolean session state; github2 types incorrectly narrow it.
state: true as unknown as string,
},
verify
)
@@ -174,6 +176,7 @@ export const router = express.Router();
router.get(
"/login",
(req, res, next) => config.GITHUB_OAUTH_ENABLED ? next() : res.status(503).json({ error: "github_oauth_disabled" }),
passport.authenticate("github", { scope: ["repo"] }), // Note the scope here
function (req: express.Request, res: express.Response) {
res.redirect("/");
@@ -182,9 +185,19 @@ router.get(
router.get(
"/auth",
passport.authenticate("github", { failureRedirect: "/" }),
function (req: express.Request, res: express.Response) {
res.redirect("/");
(req, res, next) => {
if (!config.GITHUB_OAUTH_ENABLED) return res.status(503).json({ error: "github_oauth_disabled" });
const existingId = (req.user as { user?: { id?: string } } | undefined)?.user?.id;
passport.authenticate("github", (error: Error | null, identity: Express.User | false) => {
if (error) return next(error);
if (!identity) return res.redirect("/signin");
const id = (identity as { user?: { id?: string } }).user?.id;
if (existingId && id !== existingId) return res.status(409).json({ error: "github_identity_mismatch" });
req.login(identity, loginError => {
if (loginError) return next(loginError);
res.redirect(existingId ? "/connections" : "/dashboard");
});
})(req, res, next);
}
);
+261
View File
@@ -0,0 +1,261 @@
import * as express from "express";
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
import config from "../../config";
import UserModel from "../../core/model/users/users.model";
import CredentialModel from "../../core/model/credentials/credentials.model";
import InstallationModel from "../../core/model/github-installation";
import RepositoryModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
import PullRequestModel from "../../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
import GistModel from "../../core/model/anonymizedGists/anonymizedGists.model";
import { getCredentialToken } from "../../core/credentials";
import { APP_PROVIDER, appError, appUserToken, AppInstallation, clearAppTokenCache, exchangeAppToken,
githubRequest, GitHubRepositoryInfo, reconcileAppGrant, reconcileInstallation, installationURL, saveAppGrant, selectRepositoryAccess, userInstallations } from "../../core/github-app";
import { getUser, handleError } from "./route-utils";
import { isDisabledAccount } from "./auth-utils";
type Flow = { state: string; expires: number; ownerId?: string; returnTo: string; repository?: string; install?: boolean };
declare module "express-session" {
interface SessionData { githubAppFlow?: Flow; githubInstallFlow?: Flow; githubConnectionCSRF?: string; }
}
export function safeReturnTo(value: unknown): string {
return typeof value === "string" && /^\/(?:(?:anonymize|pull-request-anonymize|gist-anonymize)(?:\/[\w-]+)?|connections|dashboard)(?:\?[^\\\r\n]*)?$/.test(value) ? value : "/connections";
}
export function consumeFlow(flow: Flow | undefined, state: unknown): Flow {
if (!flow || typeof state !== "string" || flow.state !== state || flow.expires < Date.now()) throw appError("invalid_auth_state", 400);
return flow;
}
function newFlow(ownerId: string | undefined, returnTo: unknown): Flow {
return { state: randomBytes(32).toString("hex"), expires: Date.now() + 10 * 60000, ownerId, returnTo: safeReturnTo(returnTo) };
}
function saveSession(req: express.Request) { return new Promise<void>((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); }
function enabled(req: express.Request, res: express.Response, next: express.NextFunction) {
if (!config.GITHUB_APP_ENABLED || !config.GITHUB_APP_NEW_CONNECTIONS) return res.status(503).json({ error: "github_app_disabled" });
next();
}
export const githubAppRouter = express.Router();
const router = githubAppRouter;
router.use((_req, res, next) => { res.set("Cache-Control", "no-store"); next(); });
router.get("/app/login", enabled, async (req, res) => {
try {
const ownerId = req.isAuthenticated() ? (await getUser(req)).id : undefined;
const flow = newFlow(ownerId, req.query.returnTo);
flow.install = req.query.install === "1";
if (typeof req.query.repository === "string" && /^[\w.-]+\/[\w.-]+$/.test(req.query.repository)) flow.repository = req.query.repository;
req.session.githubAppFlow = flow;
await saveSession(req);
const url = new URL("https://github.com/login/oauth/authorize");
url.searchParams.set("client_id", config.GITHUB_APP_CLIENT_ID);
url.searchParams.set("redirect_uri", config.GITHUB_APP_CALLBACK);
url.searchParams.set("state", flow.state);
res.redirect(url.toString());
} catch (error) { handleError(error, res, req); }
});
router.get("/app/callback", enabled, async (req, res) => {
try {
const pending = req.session.githubAppFlow;
delete req.session.githubAppFlow;
await saveSession(req);
const flow = consumeFlow(pending, req.query.state);
if (typeof req.query.code !== "string" || req.query.error) throw appError("github_app_authorization_cancelled", 400);
const tokens = await exchangeAppToken({ code: req.query.code, redirect_uri: config.GITHUB_APP_CALLBACK });
const profile = await githubRequest<{ id: number; login: string; avatar_url?: string }>("/user", tokens.access_token);
if (!Number.isSafeInteger(profile.id) || !profile.login) throw appError();
let user = await UserModel.findOne({ "externalIDs.github": String(profile.id) });
if (flow.ownerId) {
const current = await getUser(req);
if (current.id !== flow.ownerId || current.model.externalIDs?.github !== String(profile.id) || !user || user.id !== current.id) {
throw appError("github_identity_mismatch", 409);
}
} else if (req.isAuthenticated()) {
if (!user || user.id !== (await getUser(req)).id) throw appError("github_identity_mismatch", 409);
}
if (!user) {
// A matching login name alone is not proof of account ownership.
if (await UserModel.exists({ username: profile.login })) throw appError("github_account_link_required", 409);
user = new UserModel({ username: profile.login, externalIDs: { github: String(profile.id) }, photo: profile.avatar_url, emails: [] });
await user.save();
}
if (isDisabledAccount(user.status)) throw appError("not_connected", 403);
await saveAppGrant(user.id, tokens);
await new Promise<void>((resolve, reject) => req.login({ username: user!.username, user }, err => err ? reject(err) : resolve()));
res.redirect(flow.install ? `/github/app/install?returnTo=${encodeURIComponent(flow.returnTo)}&repository=${encodeURIComponent(flow.repository || "")}` : flow.returnTo);
} catch (error) { handleError(error, res, req); }
});
router.get("/app/install", enabled, async (req, res) => {
try {
const user = await getUser(req);
await appUserToken(user.id); // Authorization and installation are separate.
const flow = newFlow(user.id, req.query.returnTo);
req.session.githubInstallFlow = flow;
let target = installationURL();
const installations = await userInstallations(user.id);
if (typeof req.query.installationId === "string") {
const installation = installations.find(i => String(i.id) === req.query.installationId);
if (!installation) throw appError("github_app_access_required");
target = installation.account.type === "Organization"
? `https://github.com/organizations/${encodeURIComponent(installation.account.login)}/settings/installations/${installation.id}`
: `https://github.com/settings/installations/${installation.id}`;
} else if (typeof req.query.repository === "string" && /^[\w.-]+\/[\w.-]+$/.test(req.query.repository)) {
// Existing OAuth grants can preselect a private repo during initial migration.
const token = await getCredentialToken(user.id) || await appUserToken(user.id);
try {
const repo = await githubRequest<GitHubRepositoryInfo>(`/repos/${req.query.repository}`, token);
const existing = installations.find(i => i.account.id === repo.owner.id);
target = existing ? (existing.account.type === "Organization"
? `https://github.com/organizations/${encodeURIComponent(existing.account.login)}/settings/installations/${existing.id}`
: `https://github.com/settings/installations/${existing.id}`) : installationURL(repo.owner.id, [repo.id]);
} catch { /* A new private repository may be invisible until installation. */ }
}
const url = new URL(target);
url.searchParams.set("state", flow.state);
await saveSession(req);
res.redirect(url.toString());
} catch (error) { handleError(error, res, req); }
});
router.get("/app/setup", enabled, async (req, res) => {
try {
const pending = req.session.githubInstallFlow;
delete req.session.githubInstallFlow;
await saveSession(req);
if (!req.isAuthenticated()) return res.redirect("/github/app/login");
const user = await getUser(req);
// GitHub-initiated installs or configuration pages may not return state.
// Without returned state, never attach anything based on installation_id.
// A recent flow owned by this session is safe to use only for local navigation.
if (!req.query.state) return res.redirect(pending?.ownerId === user.id && pending.expires > Date.now()
? safeReturnTo(pending.returnTo) : "/connections");
const flow = consumeFlow(pending, req.query.state);
if (flow.ownerId !== user.id) throw appError("invalid_auth_state", 400);
if (req.query.setup_action !== "request") {
const installations = await userInstallations(user.id);
if (!installations.some(i => String(i.id) === req.query.installation_id)) throw appError("github_app_access_required");
}
await UserModel.updateOne({ _id: user.id }, { $set: { repositories: [] } });
res.redirect(flow.returnTo);
} catch (error) { handleError(error, res, req); }
});
router.get("/connections", async (req, res) => {
try {
const user = await getUser(req);
req.session.githubConnectionCSRF ||= randomBytes(32).toString("hex");
await saveSession(req);
const credentials = await CredentialModel.find({ ownerId: user.id }).select("provider revoked").lean();
const appConnected = credentials.some(c => c.provider === APP_PROVIDER && !c.revoked);
let installations: { id: number; account: string; suspended: boolean }[] = [];
let appErrorCode: string | undefined;
if (appConnected && config.GITHUB_APP_ENABLED) {
try {
const verified = await userInstallations(user.id);
installations = verified.map(i => ({ id: i.id, account: i.account.login, suspended: !!i.suspended_at }));
}
catch (error) { appErrorCode = error instanceof Error ? error.message : "github_app_reconnect_required"; }
}
const repos = await RepositoryModel.find({ owner: user.id, status: { $ne: "removed" } }).select("repoId source.repositoryName githubAccess status").lean();
const prs = await PullRequestModel.find({ owner: user.id, status: { $ne: "removed" } }).select("pullRequestId source.repositoryFullName githubAccess status").lean();
const gistCount = await GistModel.countDocuments({ owner: user.id, status: { $ne: "removed" } });
res.json({ csrf: req.session.githubConnectionCSRF, appEnabled: config.GITHUB_APP_ENABLED && config.GITHUB_APP_NEW_CONNECTIONS,
oauthEnabled: config.GITHUB_OAUTH_ENABLED, oauthConnected: !!(await getCredentialToken(user.id)), appConnected, appError: appErrorCode,
installations, gistCount, resources: [
...repos.map(r => ({ type: "repository", id: r.repoId, name: r.source.repositoryName, connection: r.githubAccess?.kind || "oauth", status: r.status })),
...prs.map(r => ({ type: "pull-request", id: r.pullRequestId, name: r.source.repositoryFullName, connection: r.githubAccess?.kind || "oauth", status: r.status })),
] });
} catch (error) { handleError(error, res, req); }
});
router.use("/connections", (req, res, next) => {
if (req.method !== "GET" && (!req.session.githubConnectionCSRF || req.headers["x-csrf-token"] !== req.session.githubConnectionCSRF)) {
return res.status(403).json({ error: "invalid_auth_state" });
}
next();
});
router.post("/connections/migrate", async (req, res) => {
try {
const user = await getUser(req);
const { type, id, connection, preview } = req.body;
if (typeof id !== "string" || !["repository", "pull-request"].includes(type) || !["oauth", "github-app"].includes(connection)) throw appError("invalid_connection", 400);
if (connection === "github-app" && (!config.GITHUB_APP_ENABLED || !config.GITHUB_APP_NEW_CONNECTIONS)) throw appError("github_app_disabled", 503);
const isRepo = type === "repository";
const model = isRepo ? await RepositoryModel.findOne({ repoId: id, owner: user.id }) : await PullRequestModel.findOne({ pullRequestId: id, owner: user.id });
if (!model || ["removed", "archived"].includes(model.status || "")) throw appError("repo_not_found", 404);
if (["preparing", "removing", "expiring"].includes(model.status || "")) throw appError("repository_busy", 409);
const source = model.source as { repositoryName?: string; repositoryFullName?: string; commit?: string; pullRequestId?: number };
const name = source.repositoryName || source.repositoryFullName || "";
const selected = await selectRepositoryAccess(user.id, name, connection);
const parts = name.split("/").map(encodeURIComponent).join("/");
await githubRequest(`/repos/${parts}/${isRepo ? `commits/${encodeURIComponent(source.commit || "")}` : `pulls/${source.pullRequestId}`}`, selected.token);
if (preview === true) return res.json({ eligible: true, connection });
const filter = { _id: model._id, owner: user.id, status: model.status, source: model.source,
githubAccess: model.githubAccess ? model.githubAccess : { $exists: false } };
const change = { $set: { githubAccess: selected.binding } };
const result = isRepo ? await RepositoryModel.updateOne(filter, change) : await PullRequestModel.updateOne(filter, change);
if (!result.modifiedCount) throw appError("connection_changed", 409);
res.json({ connection });
} catch (error) { handleError(error, res, req); }
});
export async function revokeGrant(ownerId: string, provider: "github" | "github-app-user") {
const token = await getCredentialToken(ownerId, provider);
const clientId = provider === "github" ? config.CLIENT_ID : config.GITHUB_APP_CLIENT_ID;
const clientSecret = provider === "github" ? config.CLIENT_SECRET : config.GITHUB_APP_CLIENT_SECRET;
if (token) {
const response = await fetch(`https://api.github.com/applications/${clientId}/grant`, { method: "DELETE",
headers: { Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, "Content-Type": "application/json", Accept: "application/vnd.github+json" },
body: JSON.stringify({ access_token: token }), signal: AbortSignal.timeout(20000) });
if (!response.ok && response.status !== 404 && response.status !== 422) throw appError("github_grant_revocation_failed", 502);
}
await CredentialModel.deleteMany({ ownerId, provider });
if (provider === "github") await UserModel.updateOne({ _id: ownerId }, { $unset: { "accessTokens.github": "", "accessTokenDates.github": "" } });
await UserModel.updateOne({ _id: ownerId }, { $set: { repositories: [] } });
}
router.post("/connections/disconnect-oauth", async (req, res) => {
try {
const user = await getUser(req);
if (!(await CredentialModel.exists({ ownerId: user.id, provider: APP_PROVIDER, revoked: { $ne: true } }))) throw appError("another_login_required", 409);
await appUserToken(user.id);
const active = { owner: user.id, status: { $ne: "removed" }, "githubAccess.kind": { $ne: "github-app" } };
if (await RepositoryModel.exists(active) || await PullRequestModel.exists(active) || await GistModel.exists({ owner: user.id, status: { $ne: "removed" } })) {
throw appError("oauth_resources_remaining", 409);
}
await revokeGrant(user.id, "github");
res.json({ disconnected: true });
} catch (error) { handleError(error, res, req); }
});
export function validWebhookSignature(body: Buffer, signature: unknown, secret: string): boolean {
if (!secret || typeof signature !== "string" || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", secret).update(body).digest();
return timingSafeEqual(expected, Buffer.from(signature.slice(7), "hex"));
}
export const githubAppWebhook = express.Router();
githubAppWebhook.post("/", express.raw({ type: "application/json", limit: "2mb" }), async (req, res) => {
if (!config.GITHUB_APP_ENABLED || !Buffer.isBuffer(req.body) || !validWebhookSignature(req.body, req.headers["x-hub-signature-256"], config.GITHUB_APP_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "invalid_webhook_signature" });
}
try {
const body = JSON.parse(req.body.toString("utf8"));
const event = req.headers["x-github-event"];
if (event === "github_app_authorization" && body.action === "revoked" && body.sender?.id) {
const users = await UserModel.find({ "externalIDs.github": String(body.sender.id) }).select("_id").lean();
for (const user of users) await reconcileAppGrant(String(user._id));
}
if (["installation", "installation_repositories"].includes(String(event)) && Number.isSafeInteger(body.installation?.id) && String(body.installation.app_id) === config.GITHUB_APP_ID) {
const installation = body.installation as AppInstallation;
// Fail closed immediately. Reconcile from GitHub, never trust event order
// to reactivate an installation. Duplicate events are safe to replay.
const filter = { appId: config.GITHUB_APP_ID, installationId: installation.id };
const revision = randomUUID();
await InstallationModel.updateOne(filter, { $set: { blocked: true, revision,
reconciliationPending: body.action !== "deleted" } }, { upsert: true });
clearAppTokenCache();
if (body.action !== "deleted") await reconcileInstallation(installation.id, revision);
}
return res.status(204).end();
} catch { return res.status(503).json({ error: "webhook_processing_failed" }); }
});
+2
View File
@@ -4,6 +4,8 @@ export const router = express.Router();
router.get("/", async (req: express.Request, res: express.Response) => {
res.json({
GITHUB_APP_ENABLED: config.GITHUB_APP_ENABLED && config.GITHUB_APP_NEW_CONNECTIONS,
GITHUB_OAUTH_ENABLED: config.GITHUB_OAUTH_ENABLED,
ENABLE_DOWNLOAD: config.ENABLE_DOWNLOAD,
MAX_FILE_SIZE: config.MAX_FILE_SIZE,
MAX_REPO_SIZE: config.MAX_REPO_SIZE,
+8 -3
View File
@@ -1,3 +1,4 @@
import { selectRepositoryAccess } from "../../core/github-app";
import * as express from "express";
import { ensureAuthenticated } from "./connection";
@@ -105,9 +106,11 @@ router.get(
async (req, res) => {
try {
const user = await getUser(req);
const access = await selectRepositoryAccess(user.id, `${req.params.owner}/${req.params.repository}`, req.query.connection);
const pullRequest = new PullRequest(
new AnonymizedPullRequestModel({
owner: user.id,
githubAccess: access.binding,
source: {
pullRequestId: parseInt(req.params.pullRequestId),
repositoryFullName: `${req.params.owner}/${req.params.repository}`,
@@ -116,7 +119,7 @@ router.get(
);
pullRequest.owner = user;
await pullRequest.download();
res.json(pullRequest.toJSON());
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
} catch (error) {
handleError(error, res, req);
}
@@ -133,7 +136,7 @@ router.get(
const user = await getUser(req);
isOwnerOrAdmin([pullRequest.owner.id], user);
res.json(pullRequest.toJSON());
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
} catch (error) {
handleError(error, res, req);
}
@@ -240,7 +243,7 @@ router.post(
).exec();
await pullRequest.updateStatus(RepositoryStatus.PREPARING);
await pullRequest.updateIfNeeded({ force: true });
res.json(pullRequest.toJSON());
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
} catch (error) {
return handleError(error, res, req);
}
@@ -264,6 +267,8 @@ router.post("/", async (req, res) => {
pullRequest.model.pullRequestId = pullRequestUpdate.pullRequestId;
pullRequest.model.anonymizeDate = new Date();
pullRequest.model.owner = user.id;
const access = await selectRepositoryAccess(user.id, pullRequestUpdate.source.repositoryFullName, pullRequestUpdate.connection);
pullRequest.model.githubAccess = access.binding;
updatePullRequestModel(pullRequest.model, pullRequestUpdate);
pullRequest.source.pullRequestId = pullRequestUpdate.source.pullRequestId;
+43 -52
View File
@@ -1,4 +1,6 @@
import { getCredentialToken } from "../../core/credentials";
import { randomUUID } from "crypto";
import { githubQuotaKey } from "../../core/github-token-context";
import { selectRepositoryAccess, boundAppToken, appError } from "../../core/github-app";
import * as express from "express";
import { ensureAuthenticated } from "./connection";
@@ -19,10 +21,9 @@ import ConferenceModel from "../../core/model/conference/conferences.model";
import AnonymousError from "../../core/AnonymousError";
import { addRemovalJob, downloadQueue } from "../../queue";
import RepositoryModel from "../../core/model/repositories/repositories.model";
import User from "../../core/User";
import { RepositoryStatus } from "../../core/types";
import { checkToken, octokit, getRedisGateResetAt, getToken } from "../../core/GitHubUtils";
import { createLogger, serializeError } from "../../core/logger";
import { octokit, getRedisGateResetAt, getToken } from "../../core/GitHubUtils";
import { createLogger } from "../../core/logger";
const logger = createLogger("route:repo");
@@ -31,27 +32,15 @@ const router = express.Router();
// user needs to be connected for all user API
router.use(ensureAuthenticated);
async function getTokenForAdmin(user: User, req: express.Request) {
if (user.isAdmin) {
try {
const existingRepo = await AnonymizedRepositoryModel.findOne(
{
"source.repositoryName": `${req.params.owner}/${req.params.repo}`,
},
{
owner: 1,
}
);
if (existingRepo?.owner) {
const token = await getCredentialToken(String(existingRepo.owner), "github", {
collection: "anonymizedrepositories", id: existingRepo._id,
});
if (token && await checkToken(token)) return token;
}
} catch (error) {
logger.warn("getToken lookup failed", serializeError(error));
}
async function previewToken(req: express.Request) {
const user = await getUser(req);
if (typeof req.query.anonymizedRepoId === "string") {
const resource = await db.getRepository(req.query.anonymizedRepoId);
isOwnerCoauthorOrAdmin(resource, user);
if (resource.model.source.repositoryName?.toLowerCase() !== `${req.params.owner}/${req.params.repo}`.toLowerCase()) throw appError("repo_not_found", 404);
return getToken(resource);
}
return (await selectRepositoryAccess(user.id, `${req.params.owner}/${req.params.repo}`, req.query.connection)).token;
}
// claim a repository
@@ -86,11 +75,12 @@ router.post("/claim", async (req, res) => {
httpStatus: 404,
});
}
const selectedAccess = await selectRepositoryAccess(user.id, `${r.owner}/${r.name}`, req.body.connection);
const repo = await getRepositoryFromGitHub({
owner: r.owner,
repo: r.name,
repositoryID: req.query.repositoryID as string,
accessToken: await user.getAccessToken(),
accessToken: selectedAccess.token,
});
if (!repo) {
throw new AnonymousError("repo_not_found", {
@@ -118,7 +108,7 @@ router.post("/claim", async (req, res) => {
await AnonymizedRepositoryModel.updateOne(
{ repoId: repoConfig.repoId },
{ $set: { owner: user.model.id } }
{ $set: { owner: user.model.id, githubAccess: selectedAccess.binding } }
).collation({ locale: "en", strength: 2 });
return res.send("Ok");
} catch (error) {
@@ -245,11 +235,7 @@ router.get(
"/:owner/:repo/",
async (req, res) => {
try {
const user = await getUser(req);
let token = await user.getAccessToken();
if (user.isAdmin) {
token = (await getTokenForAdmin(user, req)) || token;
}
const token = await previewToken(req);
const repo = await getRepositoryFromGitHub({
owner: req.params.owner,
repo: req.params.repo,
@@ -268,11 +254,7 @@ router.get(
"/:owner/:repo/branches",
async (req, res) => {
try {
const user = await getUser(req);
let token = await user.getAccessToken();
if (user.isAdmin) {
token = (await getTokenForAdmin(user, req)) || token;
}
const token = await previewToken(req);
const repository = await getRepositoryFromGitHub({
accessToken: token,
owner: req.params.owner,
@@ -296,11 +278,7 @@ router.get(
"/:owner/:repo/readme",
async (req, res) => {
try {
const user = await getUser(req);
let token = await user.getAccessToken();
if (user.isAdmin) {
token = (await getTokenForAdmin(user, req)) || token;
}
const token = await previewToken(req);
const repo = await getRepositoryFromGitHub({
owner: req.params.owner,
@@ -347,8 +325,13 @@ router.get("/:repoId/", async (req, res) => {
: fullRepo.owner.id === user.model.id
? "owner"
: "coauthor";
const repoToken = await getToken(fullRepo);
const gateResetAt = await getRedisGateResetAt(repoToken.slice(-8));
json.connection = fullRepo.model.githubAccess?.kind || "oauth";
// Connection diagnostics must remain available even when access is revoked.
let gateResetAt = 0;
try {
const repoToken = await getToken(fullRepo);
gateResetAt = await getRedisGateResetAt(githubQuotaKey(repoToken));
} catch (error) { json.connectionError = error instanceof Error ? error.message : "github_app_reconnect_required"; }
if (gateResetAt > 0) {
json.rateLimitResetAt = gateResetAt;
}
@@ -493,6 +476,7 @@ router.post(
// needed when the underlying snapshot moves. Other edits (e.g. turning
// off auto-update — see #360) just persist and return.
const sourceChanged = hasRepositorySourceChanged(repo.model, repoUpdate);
const previousAccessRevision = repo.model.githubAccess?.revision;
updateRepoModel(repo.model, repoUpdate);
const reactivating = shouldReactivateInactiveRepository(repo.model);
@@ -504,32 +488,35 @@ router.post(
if (sourceChanged) {
const parsedRepository = gh(repoUpdate.fullName);
if (!parsedRepository?.owner || !parsedRepository?.name) {
await repo.resetSate(RepositoryStatus.ERROR, "repo_not_found");
throw new AnonymousError("repo_not_found", {
object: req.body,
httpStatus: 404,
});
}
if (repoUpdate.fullName !== repo.model.source.repositoryName && user.id !== repo.owner.id) throw appError("not_owner", 403);
const sourceAccess = repo.model.githubAccess?.kind === "github-app" && repoUpdate.fullName === repo.model.source.repositoryName
? { token: await boundAppToken(repo.owner.id, repo.model.githubAccess), binding: repo.model.githubAccess }
: await selectRepositoryAccess(repo.owner.id, `${parsedRepository.owner}/${parsedRepository.name}`, repo.model.githubAccess?.kind || "oauth");
const repository = await getRepositoryFromGitHub({
accessToken: await user.getAccessToken(),
accessToken: sourceAccess.token,
owner: parsedRepository.owner,
repo: parsedRepository.name,
});
if (!repository) {
await repo.resetSate(RepositoryStatus.ERROR, "repo_not_found");
throw new AnonymousError("repo_not_found", {
object: req.body,
httpStatus: 404,
});
}
await repository.getCommitInfo(repoUpdate.source.commit, {
accessToken: await user.getAccessToken(),
accessToken: sourceAccess.token,
});
repo.model.githubAccess = { ...sourceAccess.binding, revision: randomUUID() };
repo.model.source.repositoryId = repository.model.id;
repo.model.source.repositoryName =
repository.fullName || repoUpdate.fullName;
repo.model.anonymizeDate = new Date();
await repo.remove();
await repo.remove({ accessRevision: previousAccessRevision });
}
const removeRepoFromConference = async (conferenceID: string) => {
@@ -581,17 +568,19 @@ router.post(
}
}
repo.model.conference = repoUpdate.conference;
await AnonymizedRepositoryModel.updateOne(
{ _id: repo.model._id },
const saved = await AnonymizedRepositoryModel.updateOne(
{ _id: repo.model._id, "githubAccess.revision": previousAccessRevision || { $exists: false } },
{
$set: {
options: repo.model.options,
source: repo.model.source,
githubAccess: repo.model.githubAccess,
conference: repo.model.conference,
anonymizeDate: repo.model.anonymizeDate,
},
}
).exec();
if (!saved.matchedCount) throw appError("connection_changed", 409);
if (!sourceChanged && !reactivating) {
return res.json({ status: repo.status });
}
@@ -637,8 +626,9 @@ router.post("/", async (req, res) => {
httpStatus: 404,
});
}
const selectedAccess = await selectRepositoryAccess(user.id, `${r.owner}/${r.name}`, repoUpdate.connection);
const repository = await getRepositoryFromGitHub({
accessToken: await user.getAccessToken(),
accessToken: selectedAccess.token,
owner: r.owner,
repo: r.name,
});
@@ -651,13 +641,14 @@ router.post("/", async (req, res) => {
}
await repository.getCommitInfo(repoUpdate.source.commit, {
accessToken: await user.getAccessToken(),
accessToken: selectedAccess.token,
});
const repo = new AnonymizedRepositoryModel();
repo.repoId = repoUpdate.repoId;
repo.anonymizeDate = new Date();
repo.owner = user.id;
repo.githubAccess = selectedAccess.binding;
updateRepoModel(repo, repoUpdate);
repo.source.type = "GitHubStream";
+5 -16
View File
@@ -1,6 +1,6 @@
import { revokeGrant } from "./github-app";
import CredentialModel from "../../core/model/credentials/credentials.model";
import * as express from "express";
import got from "got";
import config from "../../config";
import { ensureAuthenticated } from "./connection";
import { handleError, getUser, isOwnerOrAdmin } from "./route-utils";
@@ -190,21 +190,10 @@ router.delete("/", async (req, res) => {
).exec(),
]);
// Revoke the OAuth grant so the application no longer appears in the
// user's GitHub authorized applications. Best-effort: the account is
// scrubbed even if GitHub rejects the revocation.
try {
await got.delete(
`https://api.github.com/applications/${config.CLIENT_ID}/grant`,
{
username: config.CLIENT_ID,
password: config.CLIENT_SECRET,
headers: { accept: "application/vnd.github+json" },
json: { access_token: await user.getAccessToken() },
}
);
} catch (error) {
logger.warn("oauth grant revocation failed", serializeError(error));
// Removing one account must not uninstall a shared organization installation.
for (const provider of ["github", "github-app-user"] as const) {
try { await revokeGrant(user.id, provider); }
catch (error) { logger.warn("grant revocation failed", serializeError(error)); }
}
await CredentialModel.deleteMany({ ownerId: user.model._id });
+376
View File
@@ -0,0 +1,376 @@
const { expect } = require("chai");
const { createHmac, generateKeyPairSync, createVerify } = require("crypto");
const process = require("process");
const { setTimeout } = require("timers");
const express = require("express");
const mongoose = require("mongoose");
require("ts-node/register/transpile-only");
const config = require("../src/config").default;
const app = require("../src/core/github-app");
const { createTokenCipher } = require("../src/core/credential-crypto");
const { githubAppRouter, githubAppWebhook, validWebhookSignature, safeReturnTo, consumeFlow } = require("../src/server/routes/github-app");
const Credentials = require("../src/core/model/credentials/credentials.model").default;
const Users = require("../src/core/model/users/users.model").default;
const Installations = require("../src/core/model/github-installation").default;
const { getCredentialToken, setCredential } = require("../src/core/credentials");
const { verifyCredentials } = require("../src/core/migrate-credentials");
const { registerGitHubToken, githubQuotaKey } = require("../src/core/github-token-context");
const keys = JSON.stringify({ test: Buffer.alloc(32, 9).toString("base64") });
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const pem = privateKey.export({ type: "pkcs8", format: "pem" });
async function rejects(promise, message) {
try { await promise; } catch (error) { expect(error.message).to.equal(message); return; }
throw new Error(`Expected rejection: ${message}`);
}
describe("GitHub App protocol boundaries", () => {
it("uses a different authenticated purpose for refresh ciphertext", () => {
const cipher = createTokenCipher(keys, "test");
const access = cipher.encrypt("access", "owner", app.APP_PROVIDER);
const refresh = cipher.encrypt("refresh", "owner", app.APP_PROVIDER, "encryptedRefreshToken");
expect(cipher.decrypt(refresh, "owner", app.APP_PROVIDER, "encryptedRefreshToken")).to.equal("refresh");
expect(() => cipher.decrypt(access, "owner", app.APP_PROVIDER, "encryptedRefreshToken")).to.throw();
expect(() => cipher.decrypt(refresh, "owner", app.APP_PROVIDER)).to.throw();
});
it("validates raw webhook bytes and rejects malformed signatures", () => {
const raw = Buffer.from('{"action":"deleted"}');
const signature = "sha256=" + createHmac("sha256", "secret").update(raw).digest("hex");
expect(validWebhookSignature(raw, signature, "secret")).to.equal(true);
expect(validWebhookSignature(Buffer.from('{}'), signature, "secret")).to.equal(false);
for (const value of [undefined, "sha256=aa", [], signature.replace("sha256", "sha1")]) {
expect(validWebhookSignature(raw, value, "secret")).to.equal(false);
}
});
it("restricts callback destinations and expires state", () => {
for (const path of ["https://evil.test", "//evil.test", "/\\evil.test", "/anonymize\r\nLocation: x"]) {
expect(safeReturnTo(path)).to.equal("/connections");
}
expect(safeReturnTo("/anonymize/saved")).to.equal("/anonymize/saved");
expect(() => consumeFlow({ state: "secret", expires: Date.now() - 1 }, "secret")).to.throw("invalid_auth_state");
expect(() => consumeFlow({ state: "secret", expires: Date.now() + 1000 }, "wrong")).to.throw("invalid_auth_state");
});
it("preselects known repositories without defaulting to all repositories", () => {
const url = new globalThis.URL(app.installationURL(123, [456, 789]));
expect(url.pathname).to.match(/\/installations\/new\/permissions$/);
expect(url.searchParams.getAll("repository_ids[]")).to.deep.equal(["456", "789"]);
expect(app.installationURL(123, [])).not.to.include("/permissions");
});
it("renews a rejected installation token once without using OAuth", async () => {
const previousFetch = globalThis.fetch;
const sent = [];
let current = "ghs_before";
registerGitHubToken("ghs_original", { quotaKey: "installation:renew", renew: async force => {
if (force) current = "ghs_after";
return current;
} });
globalThis.fetch = async (_url, options) => {
sent.push(new globalThis.Headers(options.headers).get("authorization"));
return new globalThis.Response(JSON.stringify(sent.length === 1 ? { message: "Bad credentials" } : { id: 1 }), {
status: sent.length === 1 ? 401 : 200, headers: { "content-type": "application/json" },
});
};
try {
const { octokit } = require("../src/core/GitHubUtils");
const response = await octokit("ghs_original").request("GET /repos/owner/private");
expect(response.data.id).to.equal(1);
expect(sent).to.deep.equal(["token ghs_before", "token ghs_after"]);
} finally { globalThis.fetch = previousFetch; }
});
it("signs a short-lived App JWT with clock skew", () => {
const previous = { enabled: config.GITHUB_APP_ENABLED, key: config.GITHUB_APP_PRIVATE_KEY, client: config.GITHUB_APP_CLIENT_ID };
Object.assign(config, { GITHUB_APP_ENABLED: true, GITHUB_APP_PRIVATE_KEY: pem, GITHUB_APP_CLIENT_ID: "Iv.test" });
try {
const jwt = app.appJWT(1000000);
const [header, payload, signature] = jwt.split(".");
const body = JSON.parse(Buffer.from(payload, "base64url"));
expect(body).to.deep.equal({ iat: 940, exp: 1540, iss: "Iv.test" });
expect(createVerify("RSA-SHA256").update(header + "." + payload).verify(publicKey, signature, "base64url")).to.equal(true);
} finally {
Object.assign(config, { GITHUB_APP_ENABLED: previous.enabled, GITHUB_APP_PRIVATE_KEY: previous.key, GITHUB_APP_CLIENT_ID: previous.client });
}
});
});
const describeMongo = process.env.TEST_MONGODB_URI ? describe : describe.skip;
describeMongo("GitHub App credential and repository integration", function () {
this.timeout(15000);
let owner, previousConfig, previousFetch, calls, server, base, session;
const data = (suffix = "1", expires = 3600) => ({ access_token: "ghu_access" + suffix, refresh_token: "ghr_refresh" + suffix,
expires_in: expires, refresh_token_expires_in: 100000 });
before(async () => {
previousConfig = { ...config };
Object.assign(config, { GITHUB_APP_ENABLED: true, GITHUB_APP_ID: "123", GITHUB_APP_CLIENT_ID: "Iv.test",
GITHUB_APP_PRIVATE_KEY: pem, CREDENTIAL_KEYS: keys, CREDENTIAL_ACTIVE_KEY_ID: "test" });
await mongoose.connect(process.env.TEST_MONGODB_URI, { dbName: "github_app_test_" + Date.now() });
await Credentials.createIndexes();
await Installations.createIndexes();
const api = express();
api.use("/github/app/webhook", githubAppWebhook);
api.use(express.json());
api.use((req, _res, next) => {
req.session = session;
req.user = { user: owner };
req.isAuthenticated = () => true;
req.login = (identity, done) => { req.user = identity; done(); };
req.logout = done => done();
next();
});
api.use("/github", githubAppRouter);
server = await new Promise(resolve => { const listening = api.listen(0, "127.0.0.1", () => resolve(listening)); });
base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
globalThis.fetch = previousFetch;
await new Promise(resolve => server.close(resolve));
await mongoose.connection.dropDatabase();
await mongoose.disconnect();
Object.assign(config, previousConfig);
});
beforeEach(async () => {
previousFetch = globalThis.fetch;
session = { save: done => done(), githubConnectionCSRF: "csrf-test" };
calls = [];
await Credentials.deleteMany({}); await Users.deleteMany({}); await Installations.deleteMany({});
app.clearAppTokenCache();
owner = await Users.create({ username: "owner", externalIDs: { github: "10" } });
});
afterEach(() => { globalThis.fetch = previousFetch; });
function mock(handler) {
globalThis.fetch = async (url, options) => {
if (String(url).startsWith(base)) return previousFetch(url, options);
const body = options?.body ? JSON.parse(options.body) : undefined;
calls.push({ url: String(url), body, options });
const result = await handler(String(url), options, body);
return new globalThis.Response(JSON.stringify(result.body || result), { status: result.status || 200, headers: { "content-type": "application/json" } });
};
}
it("keeps OAuth and App grants separate, encrypted and migration-verifiable", async () => {
await setCredential(owner.id, "legacy-secret");
await app.saveAppGrant(owner.id, data());
expect(await getCredentialToken(owner.id)).to.equal("legacy-secret");
expect(await app.appUserToken(owner.id)).to.equal("ghu_access1");
const raw = await mongoose.connection.db.collection("credentials").find({}).toArray();
expect(JSON.stringify(raw)).not.to.include("ghr_refresh1");
expect(JSON.stringify(raw)).not.to.include("ghu_access1");
const projected = await Credentials.findOne({ ownerId: owner.id, provider: app.APP_PROVIDER }).lean();
expect(projected.encryptedRefreshToken).to.equal(undefined);
expect(projected.encryptedToken).to.equal(undefined);
expect(await verifyCredentials(mongoose.connection.db, createTokenCipher(keys, "test"))).to.deep.equal({ checked: 2, legacy: 0 });
});
it("serializes refresh and atomically rotates the token pair", async () => {
await app.saveAppGrant(owner.id, data("old", -1));
mock(async (_url, _options, body) => {
expect(body.refresh_token).to.equal("ghr_refreshold");
await new Promise(resolve => setTimeout(resolve, 30));
return data("new");
});
const tokens = await Promise.all([app.appUserToken(owner.id), app.appUserToken(owner.id)]);
expect(tokens).to.deep.equal(["ghu_accessnew", "ghu_accessnew"]);
expect(calls).to.have.length(1);
const row = await Credentials.findOne({ ownerId: owner.id, provider: app.APP_PROVIDER }).select("+encryptedRefreshToken").lean();
expect(createTokenCipher(keys, "test").decrypt(row.encryptedRefreshToken, owner.id, app.APP_PROVIDER, "encryptedRefreshToken")).to.equal("ghr_refreshnew");
});
it("does not overwrite a login that races a refresh", async () => {
await app.saveAppGrant(owner.id, data("old", -1));
mock(async () => { await app.saveAppGrant(owner.id, data("login")); return data("stale"); });
expect(await app.appUserToken(owner.id)).to.equal("ghu_accesslogin");
});
it("does not revive revoked grants or disabled users", async () => {
await app.saveAppGrant(owner.id, data());
await Credentials.updateOne({ ownerId: owner.id }, { $set: { revoked: true } });
await rejects(app.appUserToken(owner.id), "github_app_reconnect_required");
await app.saveAppGrant(owner.id, data());
await Users.updateOne({ _id: owner._id }, { $set: { status: "banned" } });
await rejects(app.appUserToken(owner.id), "github_app_reconnect_required");
});
it("never falls back to OAuth when an App cannot access the repository", async () => {
await setCredential(owner.id, "legacy-secret"); await app.saveAppGrant(owner.id, data());
mock(() => ({ installations: [] }));
await rejects(app.selectRepositoryAccess(owner.id, "owner/private"), "github_app_access_required");
const selected = await app.selectRepositoryAccess(owner.id, "owner/private", "oauth");
expect(selected.token).to.equal("legacy-secret");
expect(selected.binding.kind).to.equal("oauth");
});
it("checks the user's access before minting a repository-restricted token", async () => {
await app.saveAppGrant(owner.id, data());
const binding = { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" };
mock((url) => {
if (url.endsWith("/repositories/7")) return { id: 7 };
if (url.endsWith("/app/installations/4")) return { app_id: 123, permissions: { metadata: "read", contents: "read", pull_requests: "read" }, suspended_at: null };
if (url.endsWith("/access_tokens")) return { token: "ghs_installation", expires_at: new Date(Date.now() + 3600000).toISOString() };
throw new Error("Unexpected request");
});
expect(await app.boundAppToken(owner.id, binding)).to.equal("ghs_installation");
const mint = calls.find(c => c.url.endsWith("/access_tokens"));
expect(mint.body.repository_ids).to.deep.equal([7]);
expect(Object.values(mint.body.permissions)).to.deep.equal(["read", "read", "read"]);
expect(githubQuotaKey("ghs_installation")).to.equal("installation:4");
expect(calls[0].options.headers.Authorization).to.equal("Bearer ghu_access1");
await app.boundAppToken(owner.id, binding);
expect(calls.filter(c => c.url.endsWith("/access_tokens"))).to.have.length(1);
expect(calls.filter(c => c.url.endsWith("/repositories/7"))).to.have.length(2);
});
it("denies forged installations and grants with write permissions", async () => {
await app.saveAppGrant(owner.id, data());
const binding = { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" };
mock(url => url.endsWith("/repositories/7") ? { id: 7 } : { app_id: 999, permissions: { contents: "read" } });
await rejects(app.boundAppToken(owner.id, binding), "github_app_access_required");
mock(url => url.endsWith("/repositories/7") ? { id: 7 } : { app_id: 123, permissions: { contents: "read", issues: "write" } });
await rejects(app.boundAppToken(owner.id, binding), "github_app_permissions_invalid");
expect(calls.some(c => c.url.endsWith("/access_tokens"))).to.equal(false);
});
it("blocks removed user access even if an installation token was cached", async () => {
await app.saveAppGrant(owner.id, data());
mock(() => ({ status: 404, body: { message: "private secret details" } }));
await rejects(app.boundAppToken(owner.id, { kind: "github-app", installationId: 4, repositoryId: 7, revision: "one" }), "github_app_access_required");
expect(calls).to.have.length(1);
});
async function request(path, body, csrf = "csrf-test") {
const response = await previousFetch(base + path, { method: body ? "POST" : "GET", redirect: "manual",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: body ? JSON.stringify(body) : undefined });
return { status: response.status, location: response.headers.get("location"), data: await response.json().catch(() => null) };
}
it("links App authorization to the existing OAuth account without replacing its grant", async () => {
await setCredential(owner.id, "legacy-secret");
owner.isAdmin = true; await owner.save();
session.githubAppFlow = { state: "state", ownerId: owner.id, expires: Date.now() + 60000, returnTo: "/anonymize" };
mock(url => url.includes("/login/oauth/access_token") ? data() : { id: 10, login: "owner" });
const result = await request("/github/app/callback?state=state&code=code");
expect(result.status).to.equal(302);
expect(result.location).to.equal("/anonymize");
expect(await Users.countDocuments()).to.equal(1);
expect((await Users.findById(owner.id)).isAdmin).to.equal(true);
expect(await getCredentialToken(owner.id)).to.equal("legacy-secret");
expect(await app.appUserToken(owner.id)).to.equal("ghu_access1");
expect(session.githubAppFlow).to.equal(undefined);
const replay = await request("/github/app/callback?state=state&code=code");
expect(replay.status).to.equal(400);
expect(calls).to.have.length(2);
});
it("rejects linking a different GitHub identity", async () => {
session.githubAppFlow = { state: "state", ownerId: owner.id, expires: Date.now() + 60000, returnTo: "/connections" };
mock(url => url.includes("/login/oauth/access_token") ? data() : { id: 99, login: "other" });
const result = await request("/github/app/callback?state=state&code=code");
expect(result.status).to.equal(409);
expect(await Credentials.countDocuments()).to.equal(0);
expect(await Users.countDocuments()).to.equal(1);
});
it("migrates only the owner's resource after checking its configured commit", async () => {
const Repos = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
await app.saveAppGrant(owner.id, data());
const resource = await Repos.create({ repoId: "migration-resource", owner: owner.id, status: "ready",
source: { type: "GitHubStream", repositoryName: "owner/private", commit: "abc123", branch: "main" },
options: { terms: ["owner"], update: true } });
mock(url => {
if (url.includes("/user/installations?")) return { installations: [{ id: 4, app_id: 123, account: { id: 10, login: "owner", type: "User" } }] };
if (url.includes("/user/installations/4/repositories")) return { repositories: [{ id: 7, full_name: "owner/private" }] };
if (url.endsWith("/repositories/7")) return { id: 7 };
if (url.endsWith("/app/installations/4")) return { app_id: 123, permissions: { contents: "read", metadata: "read" } };
if (url.endsWith("/access_tokens")) return { token: "ghs_migration", expires_at: new Date(Date.now() + 3600000).toISOString() };
if (url.endsWith("/commits/abc123")) return { sha: "abc123" };
throw new Error("Unexpected request");
});
const body = { type: "repository", id: resource.repoId, connection: "github-app" };
expect((await request("/github/connections/migrate", body, "bad")).status).to.equal(403);
expect((await request("/github/connections/migrate", { ...body, preview: true })).status).to.equal(200);
expect((await Repos.findById(resource.id)).githubAccess).to.equal(undefined);
expect((await request("/github/connections/migrate", body)).status).to.equal(200);
const migrated = await Repos.findById(resource.id);
expect(migrated.githubAccess.kind).to.equal("github-app");
expect(migrated.source.toObject()).to.deep.equal(resource.source.toObject());
expect(migrated.options.terms).to.deep.equal(["owner"]);
expect(migrated.repoId).to.equal("migration-resource");
expect(calls.some(c => c.url.endsWith("/commits/abc123"))).to.equal(true);
const stranger = await Users.create({ username: "stranger", externalIDs: { github: "11" } });
owner = stranger;
expect((await request("/github/connections/migrate", body)).status).to.equal(404);
});
it("checks repository access with signed lifecycle events and rejects forged deliveries", async () => {
config.GITHUB_APP_WEBHOOK_SECRET = "webhook-secret";
const body = JSON.stringify({ action: "deleted", installation: { id: 4, app_id: 123 } });
const send = signature => previousFetch(base + "/github/app/webhook", { method: "POST", body,
headers: { "Content-Type": "application/json", "X-GitHub-Event": "installation", "X-Hub-Signature-256": signature } });
expect((await send("sha256=aa")).status).to.equal(401);
const signature = "sha256=" + createHmac("sha256", "webhook-secret").update(body).digest("hex");
expect((await send(signature)).status).to.equal(204);
expect((await send(signature)).status).to.equal(204);
expect((await Installations.findOne({ installationId: 4 })).blocked).to.equal(true);
expect(await Installations.countDocuments()).to.equal(1);
});
async function webhook(event, payload) {
config.GITHUB_APP_WEBHOOK_SECRET = "webhook-secret";
const body = JSON.stringify(payload);
return previousFetch(base + "/github/app/webhook", { method: "POST", body,
headers: { "Content-Type": "application/json", "X-GitHub-Event": event,
"X-Hub-Signature-256": "sha256=" + createHmac("sha256", "webhook-secret").update(body).digest("hex") } });
}
it("recovers a failed installation webhook on subsequent repository access", async () => {
await app.saveAppGrant(owner.id, data());
let unavailable = true;
mock(url => {
if (url.endsWith("/repositories/7")) return { id: 7 };
if (url.endsWith("/access_tokens")) return { token: "ghs_recovered", expires_at: new Date(Date.now() + 3600000).toISOString() };
if (unavailable) return { status: 503, body: {} };
return { id: 4, app_id: 123, account: { id: 10, login: "owner", type: "User" }, permissions: { contents: "read" } };
});
expect((await webhook("installation_repositories", { action: "added", installation: { id: 4, app_id: 123 } })).status).to.equal(503);
expect((await Installations.findOne({ installationId: 4 })).reconciliationPending).to.equal(true);
unavailable = false;
expect(await app.boundAppToken(owner.id, { kind: "github-app", repositoryId: 7, installationId: 4, revision: "r" })).to.equal("ghs_recovered");
expect((await Installations.findOne({ installationId: 4 })).blocked).to.equal(false);
});
it("does not let an in-flight installation check undo a deletion", async () => {
await Installations.create({ appId: "123", installationId: 4, revision: "old", blocked: true, reconciliationPending: true });
mock(async () => {
await webhook("installation", { action: "deleted", installation: { id: 4, app_id: 123 } });
return { id: 4, app_id: 123, account: { id: 10, login: "owner", type: "User" } };
});
await app.reconcileInstallation(4, "old");
expect((await Installations.findOne({ installationId: 4 })).blocked).to.equal(true);
});
it("reconciles replayed revocations without revoking a new working grant", async () => {
const payload = { action: "revoked", sender: { id: 10 } };
await app.saveAppGrant(owner.id, data());
mock(() => ({ status: 401, body: {} }));
expect((await webhook("github_app_authorization", payload)).status).to.equal(204);
expect((await Credentials.findOne({ ownerId: owner.id })).revoked).to.equal(true);
await app.saveAppGrant(owner.id, data("new"));
mock(() => ({ id: 10 }));
expect((await webhook("github_app_authorization", payload)).status).to.equal(204);
expect((await Credentials.findOne({ ownerId: owner.id })).revoked).to.equal(false);
mock(async () => {
await app.saveAppGrant(owner.id, data("racing"));
return { status: 401, body: {} };
});
await webhook("github_app_authorization", payload);
expect((await Credentials.findOne({ ownerId: owner.id })).revoked).to.equal(false);
});
it("leaves the current grant intact when revocation reconciliation is unavailable", async () => {
await app.saveAppGrant(owner.id, data("expired", -1));
mock(() => ({ status: 503, body: {} }));
expect((await webhook("github_app_authorization", { action: "revoked", sender: { id: 10 } })).status).to.equal(503);
expect((await Credentials.findOne({ ownerId: owner.id })).revoked).to.equal(false);
});
it("rejects a stale source edit before clearing files or changing status", async () => {
const Repos = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
const Repository = require("../src/core/Repository").default;
const model = await Repos.create({ repoId: "edit-race", owner: owner.id, status: "ready",
githubAccess: { kind: "oauth", revision: "old" } });
const repo = new Repository(model);
let cleared = false;
repo.resetSate = async () => { cleared = true; };
await Repos.updateOne({ _id: model._id }, { $set: { githubAccess: { kind: "github-app", revision: "new", repositoryId: 7, installationId: 4 } } });
await rejects(repo.remove({ accessRevision: "old" }), "connection_changed");
expect(cleared).to.equal(false);
expect((await Repos.findById(model._id)).status).to.equal("ready");
});
});
+82 -1
View File
@@ -8,7 +8,7 @@ const { setTimeout: delay } = require("node:timers/promises");
const publicDir = path.join(__dirname, "../public");
const bundles = ["core.min.js", "vendor.min.js"].map(name => fs.readFileSync(path.join(publicDir, "script", name), "utf8"));
async function browser(route = "/", overrides = {}) {
async function browser(route = "/", overrides = {}, storage = {}) {
const errors = [], requests = [], assets = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", error => {
@@ -50,6 +50,7 @@ async function browser(route = "/", overrides = {}) {
if (data?.__status) data = data.body;
return { ok: status < 400, status, headers: { get: () => typeof data === "string" ? "text/plain" : "application/json" }, text: async () => typeof data === "string" ? data : JSON.stringify(data) };
};
for (const [key, value] of Object.entries(storage)) window.sessionStorage.setItem(key, JSON.stringify(value));
bundles.forEach(bundle => window.eval(bundle));
const app = window.anonymousApp;
await app.router.isReady();
@@ -88,6 +89,86 @@ describe("Vue 3 UI", function () {
expect(ui.errors).to.deep.equal([]);
});
it("offers App and OAuth sign-in and direct repository access links", async function () {
ui = await browser("/signin", { "/api/options": { GITHUB_APP_ENABLED: true, GITHUB_OAUTH_ENABLED: true } });
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).not.to.equal(null);
expect(ui.window.document.querySelector('a[href="/github/login"]')).not.to.equal(null);
expect(ui.errors).to.deep.equal([]);
});
it("previews a connection migration before switching and includes CSRF protection", async function () {
const resource = { type: "repository", id: "saved", name: "owner/private", connection: "oauth", status: "ready" };
ui = await browser("/connections", {
"/api/user": { username: "owner" },
"/github/connections": { csrf: "csrf-value", appEnabled: true, appConnected: true, oauthEnabled: true, oauthConnected: true,
installations: [{ id: 4, account: "owner" }], gistCount: 0, resources: [resource] },
"/github/connections/migrate": request => request.payload.preview ? { eligible: true } : { connection: "github-app" },
});
const button = label => [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes(label));
expect(button("Switch to read-only access")).to.equal(undefined);
expect(ui.window.document.querySelector('a[href="/github/app/install?installationId=4"]')).not.to.equal(null);
button("Check read-only access").click();
await delay(30);
button("Switch to read-only access").click();
await delay(30);
const changes = ui.requests.filter(r => r.url.pathname === "/github/connections/migrate");
expect(changes).to.have.length(2);
expect(changes[0].payload.preview).to.equal(true);
expect(changes[1].payload.preview).to.equal(false);
expect(changes[1].payload.connection).to.equal("github-app");
expect(changes[1].headers["X-CSRF-Token"]).to.equal("csrf-value");
expect(ui.errors).to.deep.equal([]);
});
it("preserves redactions, identifiers and pinned commits when switching connections", async () => {
ui = await browser("/anonymize", {
"/github/connections": { appEnabled: true, oauthConnected: true },
"/api/repo/owner/repo/": { defaultBranch: "main", repo: "repo" },
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
"/api/repo/owner/repo/readme": "",
});
const url = await ui.input("#sourceUrl", "https://github.com/owner/repo");
url.dispatchEvent(new ui.window.Event("blur"));
await delay(50);
await ui.input("#terms", "Private Author");
await delay(300);
const id = await ui.input("#repoId", "chosen-id");
id.dispatchEvent(new ui.window.Event("blur"));
await ui.input("#commit", "123456abcdef");
[...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes("Read-only GitHub App")).click();
await delay(60);
expect(ui.window.document.querySelector("#terms").value).to.equal("Private Author");
expect(ui.window.document.querySelector("#repoId").value).to.equal("chosen-id");
expect(ui.window.document.querySelector("#commit").value).to.equal("123456abcdef");
expect(ui.errors).to.deep.equal([]);
});
for (const type of ["repo", "pr", "gist"]) {
it(`restores unsaved ${type} edits after granting GitHub access`, async () => {
const route = { repo: "anonymize", pr: "pull-request-anonymize", gist: "gist-anonymize" }[type];
const source = { repo: { fullName: "owner/repo", branch: "main", commit: "123456abcdef" },
pr: { repositoryFullName: "owner/repo", pullRequestId: 1 }, gist: { gistId: "311fc9" } }[type];
const sourceUrl = { repo: "https://github.com/owner/repo", pr: "https://github.com/owner/repo/pull/1", gist: "https://gist.github.com/311fc9" }[type];
const routePath = `/${route}/test`;
ui = await browser(routePath, {
[`/api/${type}/test`]: { status: "ready", source, options: { terms: ["persisted"], update: false } },
"/api/repo/owner/repo/": { defaultBranch: "main" },
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
"/api/repo/owner/repo/readme": "",
"/api/pr/owner/repo/1": { pullRequest: { title: "Test", body: "", comments: [] } },
"/api/gist/source/311fc9": { files: [], comments: [] },
}, { "github-access-draft": { path: routePath, savedAt: Date.now(), draft: {
sourceUrl, source, terms: "unsaved redaction", options: { update: false, expirationDate: "2030-01-01" },
} } });
await delay(60);
expect(ui.window.document.querySelector("#terms").value).to.equal("unsaved redaction");
if (type === "repo") expect(ui.window.document.querySelector("#commit").value).to.equal("123456abcdef");
expect(ui.window.sessionStorage.getItem("github-access-draft")).to.equal(null);
expect(ui.errors).to.deep.equal([]);
});
}
for (const type of ["gist", "pr", "repo"]) {
for (const status of ["removed", "expired", "error", "ready"]) {
it(`submits ${status} ${type} edits and exposes invalid expiration dates`, async function () {