From 126ad44b55b287f759a8e69864292b996477f619 Mon Sep 17 00:00:00 2001 From: Redchar1992 Date: Wed, 12 Aug 2026 21:38:33 +0800 Subject: [PATCH 1/2] fix(github): keep OAuth credentials behind the BFF - move state, PKCE, identity verification, encrypted tokens, REST, and Git auth server-side\n- replace browser token storage with short-lived origin-bound sessions and fail-closed cutover\n- add Deno, frontend, browser, CI, and migration coverage --- .env.example | 4 + .github/workflows/ci.yml | 18 + .github/workflows/deploy.yml | 1 + apps/remix-ide-pw/tests/git-remote.spec.ts | 18 +- .../tests/github-header-menu.spec.ts | 40 +- .../tests/github-token-modal.spec.ts | 34 +- .../tests/github-token-session.spec.ts | 49 +- apps/remix-ide-pw/tests/helpers.ts | 16 + .../tests/interaction-consistency-2.spec.ts | 38 +- apps/remix-ide/.env.example | 3 + apps/remix-ide/src/app/files/dgitProvider.js | 52 +- apps/remix-ide/src/app/tabs/git-panel-tab.js | 9 +- .../src/app/ui/landing-page/landing-page.js | 136 +- apps/remix-ide/src/lib/gist-handler.js | 42 +- apps/remix-ide/src/lib/github-auth.js | 105 +- apps/remix-ide/src/lib/github-bff.js | 98 + apps/remix-ide/src/lib/github-connection.js | 37 +- apps/remix-ide/src/lib/github-oauth.js | 126 +- .../test/audit-20260527-remediation-test.js | 31 +- .../test/audit-20260721-remediation-test.js | 6 + apps/remix-ide/test/gist-handler-test.js | 24 +- .../test/remix-220-home-parity-test.js | 4 +- apps/remix-ide/webpack.config.js | 3 + .../file-explorer/src/lib/file-explorer.tsx | 56 +- .../remix-ui/top-header/src/lib/top-header.js | 12 +- services/github-oauth/BFF_MIGRATION.md | 108 ++ services/github-oauth/README.md | 142 +- services/github-oauth/deno.json | 1 + services/github-oauth/main.ts | 1590 +++++++++++++---- services/github-oauth/main_test.ts | 601 ++++++- 30 files changed, 2458 insertions(+), 946 deletions(-) create mode 100644 apps/remix-ide/src/lib/github-bff.js create mode 100644 services/github-oauth/BFF_MIGRATION.md diff --git a/.env.example b/.env.example index 7963e0948..ae0a102b6 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,10 @@ account_password= # Optional — keep the default if your machine has enough RAM. NODE_OPTIONS=--max-old-space-size=2048 +# Public GitHub OAuth/BFF deployment origin (no trailing slash). +# Override when the team-owned Deno project/domain is ready. +TRONIDE_GITHUB_BFF_ORIGIN=https://tronide-gh-oauth.redchar1992.deno.net + # Public TronGrid API key inlined into the IDE bundle at build time. # Optional. When unset, the IDE falls back to anonymous TronGrid rate limits. # This value is readable by every browser user of the built app, so only use a diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a809f3b22..f4bc37537 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,24 @@ concurrency: cancel-in-progress: true jobs: + github-oauth-bff: + name: GitHub OAuth BFF checks + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup Deno + uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3 + with: + deno-version: v2.4.2 + + - name: Check and test BFF + working-directory: services/github-oauth + run: | + deno task check + deno task test + lint: name: Lint runs-on: ubuntu-latest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 04b236127..4ba0bfa64 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -62,6 +62,7 @@ jobs: - name: Build env: NODE_OPTIONS: --max-old-space-size=6144 + TRONIDE_GITHUB_BFF_ORIGIN: ${{ vars.TRONIDE_GITHUB_BFF_ORIGIN }} run: bash build.sh # Pages serves a CNAME file as the custom domain. A push to main always diff --git a/apps/remix-ide-pw/tests/git-remote.spec.ts b/apps/remix-ide-pw/tests/git-remote.spec.ts index 5bbc18802..e9d7f9f1e 100644 --- a/apps/remix-ide-pw/tests/git-remote.spec.ts +++ b/apps/remix-ide-pw/tests/git-remote.spec.ts @@ -1,5 +1,5 @@ import { test, expect, Page } from '@playwright/test' -import { dismissWelcomeModal } from './helpers' +import { dismissWelcomeModal, seedGithubBffSession } from './helpers' // TC-GIT-R1/R2/R3 (v2.3.2 remote-git): the Git panel exposes Clone (into a new // workspace) + Add-remote + Push/Pull against a GitHub remote, routed through @@ -22,14 +22,10 @@ async function openGitPanel (page: Page) { await page.locator('[data-id="gitPanel"]').waitFor({ state: 'visible', timeout: 15_000 }) } -async function connectFakeGithubToken (page: Page) { +async function connectFakeGithubSession (page: Page) { + await seedGithubBffSession(page, 'force-push-tester') const advanced = page.locator('[data-id="landingAdvancedToolsToggle"]') if ((await advanced.getAttribute('aria-expanded')) === 'false') await advanced.click() - await page.locator('[data-id="landingGithubTokenConnect"]').click() - const tokenInput = page.locator('[data-id="modalDialogCustomPromptText"]') - await tokenInput.waitFor({ state: 'visible', timeout: 10_000 }) - await tokenInput.fill('ghp_force_push_confirmation_test') - await page.locator('#modal-footer-ok').click() await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible({ timeout: 10_000 }) } @@ -73,14 +69,8 @@ test.describe('Git panel (remote)', () => { }) test('TC-GIT-R7: force push requires confirmation; cancel blocks it and normal push remains direct', { tag: '@gate' }, async ({ page }) => { - await page.route('https://api.github.com/user', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ login: 'force-push-tester' }) - })) await openHome(page) - await connectFakeGithubToken(page) + await connectFakeGithubSession(page) await openGitPanel(page) const init = page.locator('[data-id="gitInit"]') diff --git a/apps/remix-ide-pw/tests/github-header-menu.spec.ts b/apps/remix-ide-pw/tests/github-header-menu.spec.ts index feef9504c..67329b3ee 100644 --- a/apps/remix-ide-pw/tests/github-header-menu.spec.ts +++ b/apps/remix-ide-pw/tests/github-header-menu.spec.ts @@ -1,31 +1,21 @@ import { test, expect, Page } from '@playwright/test' -import { gotoHome } from './helpers' +import { gotoHome, seedGithubBffSession } from './helpers' // TC-GITHUB-003 (v2.3.2): once GitHub is connected, the HEADER GitHub button // must open an account menu (Reconnect / Disconnect) instead of re-running the // OAuth popup. Re-triggering OAuth while already authorized makes GitHub flash a // popup open and immediately closed (it redirects straight back) — confusing and -// pointless. Connect is done via the PAT path with a mocked /user so the test is -// deterministic (no real OAuth, no network, no compile) → @gate. +// pointless. Connected state is seeded with an opaque BFF session so the test is +// deterministic (no real OAuth, GitHub token, network, or compile) → @gate. -async function connectViaPat (page: Page) { - const advToggle = page.locator('[data-id="landingAdvancedToolsToggle"]') - if ((await advToggle.getAttribute('aria-expanded')) === 'false') await advToggle.click() - await expect(page.locator('[data-id="landingGithubTokenPanel"]')).toBeVisible({ timeout: 10_000 }) - await page.locator('[data-id="landingGithubTokenConnect"]').click() - const tokenInput = page.locator('[data-id="modalDialogCustomPromptText"]') - await tokenInput.waitFor({ state: 'visible', timeout: 10_000 }) - await tokenInput.fill('ghp_faketoken_for_test') - await page.locator('#modal-footer-ok').click() +async function connectViaBffSession (page: Page) { + await seedGithubBffSession(page) } test.describe('Header GitHub menu', () => { test('TC-GITHUB-003: a connected GitHub button opens a menu, never a re-auth popup', { tag: '@gate' }, async ({ page, context }) => { - await page.route('https://api.github.com/user', (route) => - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) })) - await gotoHome(page) - await connectViaPat(page) + await connectViaBffSession(page) const headerBtn = page.locator('[data-id="headerGithubConnect"]') await expect(headerBtn).toContainText('tron-tester', { timeout: 10_000 }) @@ -51,10 +41,8 @@ test.describe('Header GitHub menu', () => { // TC-GITHUB-005 (v2.3.2): Escape closes the header account menu. It's a // lightweight popover — outside-click alone left keyboard users stuck. test('TC-GITHUB-005: Escape closes the header GitHub menu', { tag: '@gate' }, async ({ page }) => { - await page.route('https://api.github.com/user', (route) => - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) })) await gotoHome(page) - await connectViaPat(page) + await connectViaBffSession(page) const headerBtn = page.locator('[data-id="headerGithubConnect"]') await expect(headerBtn).toContainText('tron-tester', { timeout: 10_000 }) @@ -69,10 +57,10 @@ test.describe('Header GitHub menu', () => { // empty token store because only its OWN disconnect path re-rendered the // panel; it now listens to tronideGithubConnectionChanged. test('TC-GITHUB-006: header Disconnect refreshes the Home panel button label', { tag: '@gate' }, async ({ page }) => { - await page.route('https://api.github.com/user', (route) => - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) })) await gotoHome(page) - await connectViaPat(page) + await connectViaBffSession(page) + const advanced = page.locator('[data-id="landingAdvancedToolsToggle"]') + if ((await advanced.getAttribute('aria-expanded')) === 'false') await advanced.click() const headerBtn = page.locator('[data-id="headerGithubConnect"]') await expect(headerBtn).toContainText('tron-tester', { timeout: 10_000 }) // connected state reflected on the Home button @@ -89,6 +77,11 @@ test.describe('Header GitHub menu', () => { // When NOT connected, the button must keep its original behavior: route to // the Home GitHub panel and start the connect flow (no menu). await gotoHome(page) + let capabilityChecks = 0 + await page.route('**/capabilities', (route) => { + capabilityChecks++ + return route.fulfill({ status: 404, body: 'legacy proxy' }) + }) const headerBtn = page.locator('[data-id="headerGithubConnect"]') await expect(headerBtn).toContainText('Connect GitHub') await headerBtn.click() @@ -96,5 +89,8 @@ test.describe('Header GitHub menu', () => { await expect(page.locator('[data-id="headerGithubMenu"]')).toHaveCount(0) // …and the Home GitHub token panel is brought into view (connect entry point). await expect(page.locator('[data-id="landingGithubTokenPanel"]')).toBeVisible({ timeout: 10_000 }) + await expect.poll(() => capabilityChecks).toBe(1) + await expect(page.locator('[data-id="landingGithubOAuthConnect"]')).toHaveText('Connect to GitHub') + expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.session'))).toBeNull() }) }) diff --git a/apps/remix-ide-pw/tests/github-token-modal.spec.ts b/apps/remix-ide-pw/tests/github-token-modal.spec.ts index e1f3f0113..504a73e67 100644 --- a/apps/remix-ide-pw/tests/github-token-modal.spec.ts +++ b/apps/remix-ide-pw/tests/github-token-modal.spec.ts @@ -1,32 +1,24 @@ import { test, expect } from '@playwright/test' import { dismissWelcomeModal } from './helpers' -test.describe('GitHub token modal (tab-session storage)', () => { - test('Connect token modal explains refresh-safe tab-only storage', async ({ page }) => { +test.describe('GitHub BFF credential boundary', () => { + test('GitHub panel exposes OAuth only and never a browser PAT input', async ({ page }) => { await page.goto('/') await dismissWelcomeModal(page) - - // Toggle Advanced Tools open to make GitHub Token panel visible await page.locator('[data-id="landingAdvancedToolsToggle"]').click() + const panel = page.locator('[data-id="landingGithubTokenPanel"]') + await panel.waitFor({ timeout: 30_000 }) - await page.locator('[data-id="landingGithubTokenPanel"]').waitFor({ timeout: 30_000 }) + await expect(panel).toContainText('never receives or stores the GitHub access token') + await expect(page.locator('[data-id="landingGithubTokenConnect"]')).toHaveCount(0) + await expect(page.locator('text=Connect token (PAT)')).toHaveCount(0) + await expect(page.locator('[data-id="landingGithubOAuthConnect"]')).toHaveText('Connect to GitHub') - // Sanity-check the pre-modal state: sessionStorage is empty and no legacy - // localStorage tokens survive a fresh load. - const storageBefore = await page.evaluate(() => ({ - session: window.sessionStorage.getItem('tronide.github.token'), - local: window.localStorage.getItem('tronide.github.token') + const storage = await page.evaluate(() => ({ + session: window.sessionStorage.getItem('tronide.github.session'), + token: window.sessionStorage.getItem('tronide.github.token'), + localToken: window.localStorage.getItem('tronide.github.token') })) - expect(storageBefore.session).toBeNull() - expect(storageBefore.local).toBeNull() - - await page.locator('[data-id="landingGithubTokenConnect"]').click() - - // The connection is automatic for this tab; there is no persistent-storage - // checkbox that could accidentally promote it to localStorage. - await expect(page.locator('text=Tokens stay in this browser tab')).toBeVisible({ timeout: 5_000 }) - await expect(page.locator('text=survive a refresh')).toBeVisible({ timeout: 5_000 }) - await expect(page.locator('#githubTokenRemember')).toHaveCount(0) - await expect(page.locator('text=Remember in this browser')).toHaveCount(0) + expect(storage).toEqual({ session: null, token: null, localToken: null }) }) }) diff --git a/apps/remix-ide-pw/tests/github-token-session.spec.ts b/apps/remix-ide-pw/tests/github-token-session.spec.ts index 71a558a4c..7cc721676 100644 --- a/apps/remix-ide-pw/tests/github-token-session.spec.ts +++ b/apps/remix-ide-pw/tests/github-token-session.spec.ts @@ -1,51 +1,34 @@ import { test, expect, Page } from '@playwright/test' -import { gotoHome } from './helpers' - -// TC-GITHUB-002 (v2.3.2 tab-session token): a refresh must keep the GitHub -// connection in the same tab without creating a persistent localStorage/config -// copy. The /user lookup is mocked so no real token or network is needed → @gate. +import { gotoHome, seedGithubBffSession } from './helpers' async function expandAdvancedTools (page: Page) { - const advToggle = page.locator('[data-id="landingAdvancedToolsToggle"]') - if ((await advToggle.getAttribute('aria-expanded')) === 'false') await advToggle.click() + const advanced = page.locator('[data-id="landingAdvancedToolsToggle"]') + if ((await advanced.getAttribute('aria-expanded')) === 'false') await advanced.click() await expect(page.locator('[data-id="landingGithubTokenPanel"]')).toBeVisible({ timeout: 10_000 }) } -test.describe('GitHub token survives refresh in this tab', () => { - test('TC-GITHUB-002: a reload keeps the tab-session token and connected UI', { tag: '@gate' }, async ({ page }) => { - await page.route('https://api.github.com/user', (route) => - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) })) - +test.describe('GitHub BFF session survives refresh in this tab', () => { + test('TC-GITHUB-002: reload keeps only the opaque session and connected UI', { tag: '@gate' }, async ({ page }) => { + await page.route('**/session', (route) => route.fulfill({ status: 204 })) await gotoHome(page) + await seedGithubBffSession(page) await expandAdvancedTools(page) - const panel = page.locator('[data-id="landingGithubTokenPanel"]') - - const connectBtn = page.locator('[data-id="landingGithubTokenConnect"]') - await expect(connectBtn).toHaveText('Connect token (PAT)') - - // Connect with a (mock-validated) token. - await connectBtn.click() - const tokenInput = page.locator('[data-id="modalDialogCustomPromptText"]') - await tokenInput.waitFor({ state: 'visible', timeout: 10_000 }) - await tokenInput.fill('ghp_faketoken_for_test') - await page.locator('#modal-footer-ok').click() - // Connected in the UI and mirrored only to this tab's session storage. - await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible({ timeout: 10_000 }) - await expect(panel).toContainText('tron-tester') - expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBe('ghp_faketoken_for_test') + const connect = page.locator('[data-id="landingGithubOAuthConnect"]') + await expect(connect).toHaveText('Reconnect GitHub') + await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible() + await expect(page.locator('[data-id="landingGithubTokenPanel"]')).toContainText('tron-tester') + expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.session'))).toContain('test_bff_session') + expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBeNull() expect(await page.evaluate(() => window.localStorage.getItem('tronide.github.token'))).toBeNull() - // Full re-navigation in the same tab rehydrates the token and login. await gotoHome(page) await expandAdvancedTools(page) - await expect(page.locator('[data-id="landingGithubTokenConnect"]')).toHaveText('Reconnect token', { timeout: 10_000 }) - await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible() - await expect(panel).toContainText('tron-tester') + await expect(connect).toHaveText('Reconnect GitHub') - // Disconnect clears the session mirror as well as the live state. await page.locator('[data-id="landingGithubTokenDisconnect"]').click() - await expect(page.locator('[data-id="landingGithubTokenConnect"]')).toHaveText('Connect token (PAT)') + await expect(connect).toHaveText('Connect to GitHub') + expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.session'))).toBeNull() expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBeNull() }) }) diff --git a/apps/remix-ide-pw/tests/helpers.ts b/apps/remix-ide-pw/tests/helpers.ts index c51734fa0..94386953e 100644 --- a/apps/remix-ide-pw/tests/helpers.ts +++ b/apps/remix-ide-pw/tests/helpers.ts @@ -24,6 +24,22 @@ export async function gotoHome (page: Page) { await page.locator('[data-id="landingWorkspaceStatus"]').waitFor({ timeout: 30_000 }) } +/** + * Seed only TronIDE's opaque BFF session for deterministic UI tests. This is + * not a GitHub credential; no PAT or OAuth access token enters browser storage. + */ +export async function seedGithubBffSession (page: Page, login = 'tron-tester') { + await page.evaluate(({ user }) => { + window.sessionStorage.setItem('tronide.github.session', 'test_bff_session_handle_012345678901234567890') + window.sessionStorage.setItem('tronide.github.user', user) + window.sessionStorage.removeItem('tronide.github.token') + window.localStorage.removeItem('tronide.github.token') + }, { user: login }) + await page.reload({ waitUntil: 'domcontentloaded' }) + await dismissWelcomeModal(page) + await page.locator('[data-id="landingWorkspaceStatus"]').waitFor({ timeout: 30_000 }) +} + /** data-id selector for a row of the File Explorer tree. */ export function treeItem (path: string) { return `[data-id="treeViewLitreeViewItem${path}"]` diff --git a/apps/remix-ide-pw/tests/interaction-consistency-2.spec.ts b/apps/remix-ide-pw/tests/interaction-consistency-2.spec.ts index 9ea701ed2..16fd2626c 100644 --- a/apps/remix-ide-pw/tests/interaction-consistency-2.spec.ts +++ b/apps/remix-ide-pw/tests/interaction-consistency-2.spec.ts @@ -1,5 +1,5 @@ import { test, expect, Page } from '@playwright/test' -import { dismissWelcomeModal } from './helpers' +import { dismissWelcomeModal, seedGithubBffSession } from './helpers' // Remaining R-IX cases from 交互回归测试计划.md: cross-path state consistency for // workspaces (S2), current file (S3), panel layout (S5), Home collapsibles (S6) @@ -199,11 +199,7 @@ test.describe('Interaction consistency II (R-IX remainder)', () => { await expect(page.locator('select[data-id="workspacesSelect"]')).toHaveValue('ix-layout-ws') }) - test('TC-IX-HOME-002: GitHub token Connect/Disconnect text strictly tracks the real state', async ({ page }) => { - // Validate against a mocked GitHub /user so no real token is needed; the - // panel state (not the network) is under test. - await page.route('https://api.github.com/user', (route) => - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ login: 'tron-tester' }) })) + test('TC-IX-HOME-002: GitHub BFF Connect/Disconnect text strictly tracks the real state', async ({ page }) => { await bootstrap(page) // Expand Advanced Tools so the GitHub Token panel is visible. @@ -212,32 +208,28 @@ test.describe('Interaction consistency II (R-IX remainder)', () => { const panel = page.locator('[data-id="landingGithubTokenPanel"]') await expect(panel).toBeVisible({ timeout: 10_000 }) - // Disconnected baseline: Connect reads "Connect token", no Disconnect button. - const connectBtn = page.locator('[data-id="landingGithubTokenConnect"]') - await expect(connectBtn).toHaveText('Connect token (PAT)') + // Disconnected baseline: OAuth connect is available, no Disconnect button. + const connectBtn = page.locator('[data-id="landingGithubOAuthConnect"]') + await expect(connectBtn).toHaveText('Connect to GitHub') await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toHaveCount(0) - // Connect: enter a (mock-validated) token. - await connectBtn.click() - const tokenInput = page.locator('[data-id="modalDialogCustomPromptText"]') - await tokenInput.waitFor({ state: 'visible', timeout: 10_000 }) - await tokenInput.fill('ghp_faketoken_for_test') - await page.locator('#modal-footer-ok').click() + // Seed a deterministic opaque BFF session; no GitHub credential is used. + await seedGithubBffSession(page) + if ((await advToggle.getAttribute('aria-expanded')) === 'false') await advToggle.click() - // Connected: Disconnect appears, Connect flips to "Reconnect token", the - // login surfaces. The token is scoped to this browser tab so a refresh keeps - // the connection without creating a persistent localStorage copy. + // Connected: Disconnect appears and the verified login surfaces. await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toBeVisible({ timeout: 10_000 }) - await expect(connectBtn).toHaveText('Reconnect token') + await expect(connectBtn).toHaveText('Reconnect GitHub') await expect(panel).toContainText('tron-tester') - expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBe('ghp_faketoken_for_test') + expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.session'))).toContain('test_bff_session') + expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBeNull() expect(await page.evaluate(() => window.localStorage.getItem('tronide.github.token'))).toBeNull() - // Disconnect: state reverts exactly — Disconnect gone, Connect back to - // "Connect token" (M2: text == state). Disconnect clears the tab copy. + // Disconnect revokes the BFF session and restores the exact baseline. await page.locator('[data-id="landingGithubTokenDisconnect"]').click() await expect(page.locator('[data-id="landingGithubTokenDisconnect"]')).toHaveCount(0, { timeout: 10_000 }) - await expect(connectBtn).toHaveText('Connect token (PAT)') + await expect(connectBtn).toHaveText('Connect to GitHub') + expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.session'))).toBeNull() expect(await page.evaluate(() => window.sessionStorage.getItem('tronide.github.token'))).toBeNull() }) diff --git a/apps/remix-ide/.env.example b/apps/remix-ide/.env.example index 02b5be8c0..31a12a9f1 100644 --- a/apps/remix-ide/.env.example +++ b/apps/remix-ide/.env.example @@ -7,3 +7,6 @@ gist_token= account_passphrase= account_password= + +# Public GitHub OAuth/BFF deployment origin (no trailing slash). +TRONIDE_GITHUB_BFF_ORIGIN=https://tronide-gh-oauth.redchar1992.deno.net diff --git a/apps/remix-ide/src/app/files/dgitProvider.js b/apps/remix-ide/src/app/files/dgitProvider.js index 0fd4650a3..313e21db2 100644 --- a/apps/remix-ide/src/app/files/dgitProvider.js +++ b/apps/remix-ide/src/app/files/dgitProvider.js @@ -29,6 +29,7 @@ import { saveAs } from 'file-saver' import * as githubAuth from '../../lib/github-auth' +import { GITHUB_BFF } from '../../lib/github-bff' const JSZip = require('jszip') const path = require('path') @@ -39,7 +40,7 @@ const axios = require('axios') // browser cannot reach github.com's git endpoints directly; isomorphic-git // routes every request through this proxy (our Deno OAuth service, which also // hosts the `/git/` forwarder). Change here if the Deno deployment URL changes. -const GIT_CORS_PROXY = 'https://tronide-gh-oauth.redchar1992.deno.net/git' +const GIT_CORS_PROXY = GITHUB_BFF.origin + '/git' // A trailing slash in a pasted repo URL (github.com/owner/repo/) survives into // the proxied path as '//', which the proxy's SSRF guard rejects — normalize @@ -49,15 +50,26 @@ function normalizeGitUrl (url) { return String(url).trim().replace(/\/+$/, '') } -// isomorphic-git auth callback. GitHub accepts the personal/OAuth token as the -// HTTP-basic *username* with a fixed password. The token now lives only in -// memory (lib/github-auth) — never in sessionStorage — so we read it from there. -// If absent (e.g. after a full reload), fail loudly so the UI can point the user -// at "Connect to GitHub" instead of hanging on a 401 loop. +// Every operation proactively sends only TronIDE's opaque BFF session header, +// so private GitHub discovery succeeds on the first request. Keep onAuth as a +// retry callback in case isomorphic-git receives a 401 after a session refresh. function gitOnAuth () { - const token = githubAuth.getToken() - if (!token) throw new Error('Connect GitHub first (use the "Connect to GitHub" button).') - return { username: token, password: 'x-oauth-basic' } + const session = githubAuth.getSession() + if (!session) throw new Error('Connect GitHub first (use the "Connect to GitHub" button).') + return { headers: { 'X-TronIDE-Session': session } } +} + +function gitOnAuthFailure () { + // A second 401 means the BFF session is no longer usable (the server also + // deletes it when GitHub rejects the upstream token). Remove the stale UI + // state instead of leaving GitHub displayed as connected. + githubAuth.clearSession() + return { cancel: true } +} + +function gitSessionHeaders () { + const session = githubAuth.getSession() + return session ? { 'X-TronIDE-Session': session } : {} } // Pinata calls carry the pinata_secret_api_key in axios request headers. On @@ -553,11 +565,13 @@ class DGitProvider extends Plugin { dir: gitCmd.dir || config.dir, http, corsProxy: GIT_CORS_PROXY, + headers: gitSessionHeaders(), url: normalizeGitUrl(gitCmd.url), ref: gitCmd.branch || undefined, singleBranch: gitCmd.singleBranch === true, depth: gitCmd.depth || 1, - onAuth: gitOnAuth + onAuth: gitOnAuth, + onAuthFailure: gitOnAuthFailure }) await this.call('fileManager', 'refresh') this._emitGitChanged('clone') @@ -573,12 +587,14 @@ class DGitProvider extends Plugin { try { const { config, gitCmd } = await this._mutationContext(cmd || {}) const singleBranch = gitCmd.singleBranch === true || !!gitCmd.branch + const remote = gitCmd.remote || 'origin' const result = await git.fetch({ ...config, http, corsProxy: GIT_CORS_PROXY, + headers: gitSessionHeaders(), url: normalizeGitUrl(gitCmd.url), - remote: gitCmd.remote || 'origin', + remote, // isomorphic-git tries to resolve the local HEAD when ref is omitted, // which fails for the exact Add-remote-on-an-unborn-repo flow. Remote // HEAD is a safe negotiation target while singleBranch=false still @@ -588,7 +604,8 @@ class DGitProvider extends Plugin { // what makes Add remote / Fetch populate the complete branch picker. singleBranch, depth: gitCmd.depth || 1, - onAuth: gitOnAuth + onAuth: gitOnAuth, + onAuthFailure: gitOnAuthFailure }) this._emitGitChanged('fetchRemote') return result @@ -626,11 +643,13 @@ class DGitProvider extends Plugin { cache, http, corsProxy: GIT_CORS_PROXY, + headers: gitSessionHeaders(), url: normalizeGitUrl(gitCmd.url), remote, ref, singleBranch: true, - onAuth: gitOnAuth + onAuth: gitOnAuth, + onAuthFailure: gitOnAuthFailure }) // Fetch is intentionally allowed to update remote-tracking refs, just @@ -716,15 +735,18 @@ class DGitProvider extends Plugin { const mutationToken = this._beginGitMutation('pushing remote changes') try { const { config, gitCmd } = await this._mutationContext(cmd || {}) + const remote = gitCmd.remote || 'origin' const result = await git.push({ ...config, http, corsProxy: GIT_CORS_PROXY, + headers: gitSessionHeaders(), url: normalizeGitUrl(gitCmd.url), - remote: gitCmd.remote || 'origin', + remote, ref: gitCmd.branch || undefined, force: !!gitCmd.force, - onAuth: gitOnAuth + onAuth: gitOnAuth, + onAuthFailure: gitOnAuthFailure }) if (result && result.ok === false) { const reason = (result.error) || (result.errors && result.errors.join('; ')) || 'push rejected' diff --git a/apps/remix-ide/src/app/tabs/git-panel-tab.js b/apps/remix-ide/src/app/tabs/git-panel-tab.js index 5ed620dcc..d47cbd436 100644 --- a/apps/remix-ide/src/app/tabs/git-panel-tab.js +++ b/apps/remix-ide/src/app/tabs/git-panel-tab.js @@ -174,12 +174,11 @@ export class GitPanelTab extends ViewPlugin { } } - // True if the OAuth flow has a GitHub token in memory (lib/github-auth — never - // web storage). Push/Pull/private-clone need it; we only HINT the user toward - // the existing "Connect to GitHub" button rather than reimplementing auth here. - // After a full reload the token is gone, so this reads false until reconnect. + // True when this tab has an opaque BFF session. Push/Pull/private-clone need + // it; we only point users at the existing Connect GitHub flow rather than + // reimplementing authentication here. _hasGithubToken () { - try { return !!githubAuth.getToken() } catch (e) { return false } + try { return githubAuth.isConnected() } catch (e) { return false } } // The first remote's URL (origin if present), or '' if no remote configured. diff --git a/apps/remix-ide/src/app/ui/landing-page/landing-page.js b/apps/remix-ide/src/app/ui/landing-page/landing-page.js index a096567d1..7c7ec4083 100644 --- a/apps/remix-ide/src/app/ui/landing-page/landing-page.js +++ b/apps/remix-ide/src/app/ui/landing-page/landing-page.js @@ -25,6 +25,7 @@ import { workspace } from '@remix-project/remix-lib' import JSZip from 'jszip' import { connectWithGithubOAuth } from '../../../lib/github-oauth' import * as githubAuth from '../../../lib/github-auth' +import { githubRequest as githubBffRequest, revokeSession } from '../../../lib/github-bff' import { disconnectGithub } from '../../../lib/github-connection' const yo = require('yo-yo') @@ -1457,20 +1458,13 @@ export class LandingPage extends ViewPlugin { open: false, items: readJsonStorage('tronide.home.notifications', []).slice(0, 8) } - // GitHub tokens live in this tab's session (lib/github-auth): a refresh keeps - // the connection, while closing the tab clears it. Never promote the token - // to localStorage/config. Defensive: scrub only the historical persistent - // copies left by older versions. + // Only the opaque, origin-bound BFF session lives in this tab. GitHub's + // access token stays encrypted server-side and is never written by the UI. + // Defensive: scrub historical browser token copies from older versions. try { window.localStorage.removeItem('tronide.github.token') } catch (error) { console.debug('[home] failed to clear legacy github token', error) } try { window.localStorage.removeItem('tronide.github.user') } catch (error) { console.debug('[home] failed to clear legacy github user', error) } - // `githubTokenState` is a render-side mirror of the tab-session store; the - // authoritative copy is githubAuth.getToken()/getLogin(). - const githubTokenState = { - get token () { return githubAuth.getToken() }, - // live like `token` — a snapshot here went stale the moment the login - // changed through another surface (header connect/disconnect). Existing - // call sites assign the /user API object; forward that to the store so - // every consumer (header included) sees it via the changed event. + const githubSessionState = { + get connected () { return githubAuth.isConnected() }, get user () { return githubAuth.getLogin() ? { login: githubAuth.getLogin() } : null }, set user (value) { githubAuth.setLogin(value && value.login ? value.login : '') } } @@ -1517,14 +1511,8 @@ export class LandingPage extends ViewPlugin { return detail ? `GitHub ${status}: ${detail}` : `GitHub request failed (${status || 'network error'})` } const githubRequest = async (path, options = {}) => { - if (!githubTokenState.token) throw new Error('Connect a GitHub token first.') - const response = await window.fetch(`https://api.github.com${path}`, Object.assign({ - headers: Object.assign({ - Authorization: `Bearer ${githubTokenState.token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28' - }, options.headers || {}) - }, options)) + if (!githubSessionState.connected) throw new Error('Connect GitHub first.') + const response = await githubBffRequest(path, options) const text = await response.text() let payload = {} try { @@ -1539,70 +1527,36 @@ export class LandingPage extends ViewPlugin { } return payload } - const saveGithubToken = (token) => { - // github-auth owns the tab-scoped sessionStorage mirror. Every reader - // (gist publish/load, header, dgitProvider) still goes through getToken(), - // and no persistent localStorage/config copy is written. - githubAuth.setToken(token, githubAuth.getLogin()) - } - // Update the connected GitHub login in the tab-session store; setLogin (like - // setToken/clearToken) dispatches 'tronideGithubConnectionChanged' so the - // header and any other live consumer update immediately. - const persistGithubUser = (login) => { - githubAuth.setLogin(login) - } - const clearGithubToken = (silent = false) => { - // Full disconnect (tab-session token + legacy web storage + the config-backed - // Settings gist token). Shared with the header GitHub menu via - // lib/github-connection so the two disconnect paths cannot diverge. + const clearGithubSession = (silent = false) => { disconnectGithub() - if (!silent) addNotification('GitHub disconnected', 'GitHub token was cleared from this browser tab.') + if (!silent) addNotification('GitHub disconnected', 'The TronIDE GitHub session was revoked.') refreshHomeSection('landingGithubTokenPanel', renderGithubTokenPanel) } - const connectGithubToken = () => { - const message = yo` -
-
Paste a fine-grained GitHub token — or skip this and use Connect GitHub in the header to sign in with OAuth instead.
-
Tokens stay in this browser tab, survive a refresh, and are cleared when you close it. Recommended scopes: Contents read for import; Contents read/write only when committing. Limit the token to selected repositories.
-
- ` - modalDialogCustom.promptPassphrase('Connect GitHub Token', message, '', async (token) => { - if (!token) return - // The prompt closes on save and token validation is async (a network - // round-trip to api.github.com), so without immediate + persistent - // feedback the gap and the transient tooltip read as "save did nothing". - tooltip('Validating GitHub token…') - try { - saveGithubToken(token) - githubTokenState.user = await githubRequest('/user') - const login = (githubTokenState.user && githubTokenState.user.login) || 'Token validated.' - addNotification('GitHub connected', login) - refreshHomeSection('landingGithubTokenPanel', renderGithubTokenPanel) - tooltip(`GitHub connected as ${login}`) - persistGithubUser(githubTokenState.user && githubTokenState.user.login) - } catch (error) { - clearGithubToken(true) - const msg = error.message || 'GitHub token rejected.' - addNotification('GitHub connection failed', msg, 'error') - tooltip(msg) - } - }, null, true) - } - // OAuth popup connect — no PAT pasting. Reuses the same token sinks as the - // PAT path (saveGithubToken mirrors the gist token; persistGithubUser syncs - // the header), so gist + Git-panel keep working unchanged. + // The popup returns an opaque TronIDE session, never a GitHub token. const connectGithubOAuth = async () => { - tooltip('Opening GitHub authorization…') + tooltip('Opening GitHub…') + const previousSession = githubAuth.getSession() + const previousLogin = githubAuth.getLogin() try { - const { token, login } = await connectWithGithubOAuth() - saveGithubToken(token) - githubTokenState.user = login ? { login } : await githubRequest('/user') - const name = (githubTokenState.user && githubTokenState.user.login) || 'Connected.' + const { session, login } = await connectWithGithubOAuth() + githubAuth.setSession(session, login) + if (previousSession && previousSession !== session) { + revokeSession(previousSession).catch((error) => console.debug('[home] previous GitHub session revocation failed', error)) + } + githubSessionState.user = { login } + const name = (githubSessionState.user && githubSessionState.user.login) || 'Connected.' addNotification('GitHub connected', name) refreshHomeSection('landingGithubTokenPanel', renderGithubTokenPanel) tooltip(`GitHub connected as ${name}`) - persistGithubUser(githubTokenState.user && githubTokenState.user.login) } catch (error) { + // A cancelled/failed reconnect must not destroy the still-valid old + // session. Only roll back if a replacement session was already stored. + const failedReplacement = githubAuth.getSession() + if (failedReplacement && failedReplacement !== previousSession) { + githubAuth.clearSession() + revokeSession(failedReplacement).catch((revokeError) => console.debug('[home] failed GitHub replacement session revocation failed', revokeError)) + if (previousSession) githubAuth.setSession(previousSession, previousLogin) + } const msg = (error && error.message) || 'GitHub authorization failed.' addNotification('GitHub connection failed', msg, 'error') tooltip(msg) @@ -1637,14 +1591,13 @@ export class LandingPage extends ViewPlugin { } }) } - const copyGithubTokenChecklist = async () => { + const copyGithubConnectionChecklist = async () => { const checklist = [ - 'TronIDE GitHub Token checklist', - '- Use a fine-grained personal access token.', - '- Scope read access for import; add contents read/write only for commits.', - '- Limit the token to selected repositories.', - '- Prefer session storage on shared devices.', - '- Revoke the token from GitHub settings if the browser is untrusted.' + 'TronIDE GitHub connection checklist', + '- Confirm the account shown by GitHub before authorizing.', + '- Grant organization access only when the repository requires it.', + '- Disconnect TronIDE when using a shared browser.', + '- Revoke TronIDE from GitHub settings if the browser is untrusted.' ].join('\n') // clipboard.writeText returns a PROMISE that rejects asynchronously // (blocked permission / insecure context) — the old sync try/catch never @@ -1652,8 +1605,8 @@ export class LandingPage extends ViewPlugin { // click looked like nothing happened. Await it and always show a toast. try { await window.navigator.clipboard.writeText(checklist) - addNotification('GitHub checklist copied', 'Token permission checklist copied to clipboard.', 'github') - tooltip('GitHub token checklist copied to clipboard.') + addNotification('GitHub checklist copied', 'Connection checklist copied to clipboard.', 'github') + tooltip('GitHub connection checklist copied to clipboard.') } catch (error) { // Clipboard unavailable — show the checklist itself so the user still gets it. tooltip(checklist) @@ -2026,7 +1979,7 @@ export class LandingPage extends ViewPlugin { ['TronLink readiness', 'Check injection, account, and Nile/Shasta/Mainnet host before deployment.', checkTronLinkReadiness, 'landingRecipeTronLink'], ['Nile deploy checklist', 'Compile, switch TronLink to Nile, set feeLimit, deploy, then verify on TronScan.', startInjectedTronWeb, 'landingRecipeNileDeploy'], ['Contract verification', 'Compile, select the deployed main contract, download its flattened .sol, then upload it on TronScan.', openContractVerification, 'landingRecipeVerification'], - ['GitHub token safety', 'Copy the recommended token permission checklist before using private read/write.', copyGithubTokenChecklist, 'landingRecipeGithubToken'] + ['GitHub connection safety', 'Copy account and shared-browser safety checks.', copyGithubConnectionChecklist, 'landingRecipeGithubToken'] ] return yo`
@@ -2049,17 +2002,16 @@ export class LandingPage extends ViewPlugin { const renderGithubTokenPanel = () => yo`
-

${githubIcon} GitHub Token

- ${githubTokenState.user && githubTokenState.user.login ? githubTokenState.user.login : 'Not connected'} +

${githubIcon} GitHub access

+ ${githubSessionState.user && githubSessionState.user.login ? githubSessionState.user.login : 'Not connected'}
-
Use a fine-grained PAT with the Contents permission for repo import/push. The token stays in this tab, survives refresh, and is never written to persistent browser storage — use trusted devices only.
+
GitHub authorization is handled by the TronIDE BFF. This browser receives only a revocable TronIDE session; it never receives or stores the GitHub access token.
- - + - - ${githubTokenState.token ? yo`` : ''} + + ${githubSessionState.connected ? yo`` : ''}
` diff --git a/apps/remix-ide/src/lib/gist-handler.js b/apps/remix-ide/src/lib/gist-handler.js index f62477f7f..a754492b9 100644 --- a/apps/remix-ide/src/lib/gist-handler.js +++ b/apps/remix-ide/src/lib/gist-handler.js @@ -22,40 +22,22 @@ var modalDialogCustom if (typeof window !== 'undefined') { modalDialogCustom = require('../app/ui/modal-dialog-custom') } -// Tab-session GitHub connect token used to authenticate -// gist loads; tokens are never read from web storage or config. +// Authenticated gist reads go through the BFF. The browser has only an opaque +// TronIDE session handle; GitHub's credential remains encrypted server-side. var githubAuth = require('./github-auth') +var githubBff = require('./github-bff') var githubGistSecurity = loadGithubGistSecurity() var normalizeGistId = require('./normalize-gist-id') -// Read an access token to authenticate gist loads (authenticated GitHub requests -// get a far higher rate limit than anonymous, which was causing "API rate limit -// exceeded"). Uses only the in-memory GitHub connect token (lib/github-auth, set -// by the Home/Header "Connect GitHub" flow) — the legacy Settings-tab PAT channel -// is retired. Returns '' when not connected so we transparently load the gist -// anonymously. -function getGistAccessToken () { - try { - return String(githubAuth.getToken() || '').trim() - } catch (error) { - console.debug('[gistHandler] in-memory github token unavailable; loading gist anonymously', error) - } - return '' -} - -// Only same-origin (api.github.com) HTTPS gist URLs are ever fetched. The gist id is constrained to -// hex characters by `getGistId`, so this fetch cannot be redirected to attacker-controlled origins -// even on a future browser without strict redirect semantics; the IDE's CSP would also block it. function fetchGist (gistId) { - var headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'tron-remix' } - // Authenticate when a token is configured to lift the GitHub rate limit; anonymous otherwise. - var token = getGistAccessToken() - if (token) headers.Authorization = 'token ' + token - return window.fetch('https://api.github.com/gists/' + encodeURIComponent(gistId), { - method: 'GET', - headers: headers, - redirect: 'error' - }).then(function (response) { + var request = githubAuth.isConnected() + ? githubBff.githubRequest('/gists/' + encodeURIComponent(gistId), { method: 'GET' }) + : window.fetch('https://api.github.com/gists/' + encodeURIComponent(gistId), { + method: 'GET', + headers: { Accept: 'application/vnd.github+json' }, + redirect: 'error' + }) + return request.then(function (response) { return response.text().then(function (text) { var payload = {} try { payload = text ? JSON.parse(text) : {} } catch (parseError) { @@ -81,7 +63,7 @@ function fetchGist (gistId) { // safelisted, so adding it forces a preflight the raw host doesn't answer — every // truncated-file backfill then fails with a CORS error (0 bytes), which surfaced as // "Could not load the content ... a rate limit applies" even with a token configured. -// The token still authenticates the api.github.com call in `fetchGist` to lift the rate limit. +// The BFF authenticates the metadata call; raw content remains anonymous. function fetchGistRawContent (rawUrl) { var parsed try { parsed = new URL(rawUrl) } catch (error) { return Promise.reject(new Error('invalid raw gist url')) } diff --git a/apps/remix-ide/src/lib/github-auth.js b/apps/remix-ide/src/lib/github-auth.js index 49d75deae..0f7471dab 100644 --- a/apps/remix-ide/src/lib/github-auth.js +++ b/apps/remix-ide/src/lib/github-auth.js @@ -7,21 +7,16 @@ 'use strict' /** - * Tab-scoped GitHub credential store. + * Tab-scoped TronIDE BFF session store. * - * The GitHub access token obtained from the OAuth popup (or a pasted PAT) is - * mirrored to sessionStorage so a normal refresh keeps the connection. It is - * deliberately never written to localStorage or the config store: closing the - * tab still clears it, and another tab does not inherit it. - * - * Every reader of the connect token goes through getToken()/getLogin(); the - * header (and any other live consumer) subscribes via onChange() and/or the - * existing `tronideGithubConnectionChanged` window event, which setToken/ - * clearToken keep dispatching so nothing else has to change. + * The browser stores only an opaque, origin-bound TronIDE session handle. The + * GitHub access token is encrypted in the Deno BFF and is never returned to + * this module, web storage, frontend state, or browser request headers. */ -const TOKEN_KEY = 'tronide.github.token' +const SESSION_KEY = 'tronide.github.session' const USER_KEY = 'tronide.github.user' +const LEGACY_TOKEN_KEY = 'tronide.github.token' function readSession (key) { try { @@ -40,30 +35,35 @@ function writeSession (key, value) { if (value) window.sessionStorage.setItem(key, value) else window.sessionStorage.removeItem(key) } catch (error) { - // Privacy modes can deny web storage. The in-memory copy still keeps the - // current page usable; only refresh continuity is lost in that case. console.debug(`[githubAuth] failed to persist ${key} for this tab`, error) } } -// Module-level singleton state is authoritative while the page is running and -// is hydrated from this tab's session after a refresh. -let _token = readSession(TOKEN_KEY) -let _login = _token ? readSession(USER_KEY) : '' +// Never migrate a legacy GitHub token into the BFF session slot. Remove it on +// module load so an upgrade immediately closes the old credential channel. +writeSession(LEGACY_TOKEN_KEY, '') +try { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(LEGACY_TOKEN_KEY) + window.localStorage.removeItem(USER_KEY) + window.localStorage.removeItem(SESSION_KEY) + } +} catch (error) { + console.debug('[githubAuth] failed to scrub legacy persistent GitHub state', error) +} +let _session = readSession(SESSION_KEY) +let _login = _session ? readSession(USER_KEY) : '' +if (!_session) writeSession(USER_KEY, '') const _listeners = new Set() function notify () { - // 1) Local subscribers (e.g. components that imported this module directly). for (const cb of Array.from(_listeners)) { try { - cb({ connected: !!_token, login: _login }) + cb({ connected: !!_session, login: _login }) } catch (error) { - // A broken listener must not break the connect/disconnect flow or starve - // the other listeners — surface it but keep going (no silent failure). console.debug('[githubAuth] onChange listener threw', error) } } - // 2) The existing cross-component signal the header already listens for. try { if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { window.dispatchEvent(new CustomEvent('tronideGithubConnectionChanged')) @@ -73,67 +73,58 @@ function notify () { } } -/** - * @returns {string} the current tab-session token, '' when not connected. - */ -export function getToken () { - return _token +/** @returns {string} opaque TronIDE BFF session handle, or ''. */ +export function getSession () { + return _session } -/** - * @returns {string} the connected GitHub login, '' when unknown/not connected. - */ +/** @returns {boolean} whether this tab has a BFF session handle. */ +export function isConnected () { + return !!_session +} + +/** @returns {string} verified GitHub login, or ''. */ export function getLogin () { return _login } /** - * Store the connected token (and optional login) for this tab and notify listeners. - * @param {string} token the GitHub access token - * @param {string} [login] the GitHub login, if already known + * Store the opaque BFF session and verified login for this tab. + * @param {string} session + * @param {string} [login] */ -export function setToken (token, login) { - _token = String(token || '').trim() +export function setSession (session, login) { + _session = String(session || '').trim() if (login !== undefined) _login = String(login || '').trim() - writeSession(TOKEN_KEY, _token) - writeSession(USER_KEY, _token ? _login : '') + writeSession(SESSION_KEY, _session) + writeSession(USER_KEY, _session ? _login : '') + writeSession(LEGACY_TOKEN_KEY, '') notify() } -/** - * Update just the connected login (e.g. once the /user lookup resolves) and - * notify listeners. No-op effect on the token. - * @param {string} [login] - */ +/** @param {string} [login] */ export function setLogin (login) { _login = String(login || '').trim() - writeSession(USER_KEY, _token ? _login : '') + writeSession(USER_KEY, _session ? _login : '') notify() } -/** - * Clear the token and login from memory and this tab, then notify listeners. - */ -export function clearToken () { - _token = '' +/** Clear the local BFF session handle and identity. */ +export function clearSession () { + _session = '' _login = '' - writeSession(TOKEN_KEY, '') + writeSession(SESSION_KEY, '') writeSession(USER_KEY, '') + writeSession(LEGACY_TOKEN_KEY, '') notify() } -/** - * Subscribe to connect/disconnect changes. - * @param {(state: { connected: boolean, login: string }) => void} cb - */ +/** @param {(state: { connected: boolean, login: string }) => void} cb */ export function onChange (cb) { if (typeof cb === 'function') _listeners.add(cb) } -/** - * Unsubscribe a previously-registered listener. - * @param {Function} cb - */ +/** @param {Function} cb */ export function offChange (cb) { _listeners.delete(cb) } diff --git a/apps/remix-ide/src/lib/github-bff.js b/apps/remix-ide/src/lib/github-bff.js new file mode 100644 index 000000000..f12351906 --- /dev/null +++ b/apps/remix-ide/src/lib/github-bff.js @@ -0,0 +1,98 @@ +/* + * Copyright © 2026 TronIDE + * + * Licensed under the Apache License, Version 2.0 (the "License"). + */ + +'use strict' + +import * as githubAuth from './github-auth.js' + +export const GITHUB_BFF = { + origin: String(process.env.TRONIDE_GITHUB_BFF_ORIGIN || 'https://tronide-gh-oauth.redchar1992.deno.net').replace(/\/$/, ''), + sessionHeader: 'X-TronIDE-Session' +} + +function bffUrl (path) { + const normalized = String(path || '') + if (!normalized.startsWith('/')) throw new Error('GitHub BFF path must be absolute.') + return GITHUB_BFF.origin + normalized +} + +function sessionHeaders (session, headers) { + const result = new Headers(headers || {}) + const handle = String(session || '').trim() + if (handle) result.set(GITHUB_BFF.sessionHeader, handle) + return result +} + +/** + * Make a request to the TronIDE BFF with the current opaque session handle. + * @param {string} path + * @param {RequestInit & { session?: string }} [options] + */ +export async function request (path, options = {}) { + const session = options.session === undefined ? githubAuth.getSession() : options.session + const init = Object.assign({}, options, { + headers: sessionHeaders(session, options.headers), + redirect: 'error' + }) + delete init.session + const response = await window.fetch(bffUrl(path), init) + // Do not leave an expired/revoked handle advertised as connected. An + // explicit request may target an old handle during reconnect/disconnect; in + // that case, never clear the newer current session. + if (response.status === 401 && session && githubAuth.getSession() === session) { + githubAuth.clearSession() + } + return response +} + +/** Fail closed when the independently deployed OAuth service is still legacy. */ +export async function assertBffReady () { + let response + try { + response = await window.fetch(bffUrl('/capabilities'), { + headers: { Accept: 'application/json' }, + redirect: 'error' + }) + } catch (_error) { + throw new Error('GitHub connection is temporarily unavailable while its secure backend is being upgraded.') + } + if (!response.ok) { + throw new Error('GitHub connection is temporarily unavailable while its secure backend is being upgraded.') + } + const capabilities = await response.json().catch(() => null) + if (!capabilities || capabilities.authMode !== 'bff-v1' || capabilities.githubTokenInBrowser !== false) { + throw new Error('GitHub connection is temporarily unavailable while its secure backend is being upgraded.') + } + return capabilities +} + +/** Route an allow-listed GitHub REST path through the BFF. */ +export function githubRequest (path, options = {}) { + if (!githubAuth.getSession()) return Promise.reject(new Error('Connect GitHub first.')) + const normalized = String(path || '') + if (!normalized.startsWith('/')) return Promise.reject(new Error('Invalid GitHub API path.')) + return request('/api' + normalized, options) +} + +/** Validate and hydrate the current BFF session. */ +export async function validateSession () { + if (!githubAuth.getSession()) return null + const response = await request('/session', { method: 'GET' }) + if (!response.ok) { + if (response.status === 401) githubAuth.clearSession() + return null + } + const state = await response.json() + githubAuth.setLogin(state.login || '') + return state +} + +/** Revoke a BFF session. The caller clears local state even when this fails. */ +export function revokeSession (session) { + const handle = String(session || '').trim() + if (!handle) return Promise.resolve() + return request('/session', { method: 'DELETE', session: handle }).then(() => undefined) +} diff --git a/apps/remix-ide/src/lib/github-connection.js b/apps/remix-ide/src/lib/github-connection.js index 7752b4dd3..cc39380d7 100644 --- a/apps/remix-ide/src/lib/github-connection.js +++ b/apps/remix-ide/src/lib/github-connection.js @@ -7,30 +7,27 @@ 'use strict' import * as githubAuth from './github-auth' +import { revokeSession } from './github-bff' const globalRegistry = require('../global/registry') /** - * Fully disconnect GitHub from this browser session. Shared by BOTH the Home - * panel "Disconnect" and the header GitHub menu so the two can never drift into - * a half-cleanup (a disconnect that leaves a usable token copy behind): - * - * - drop the authoritative tab-session token+login (githubAuth notifies every - * live subscriber — the header re-reads "not connected" immediately); - * - scrub the legacy web-storage copies older versions may have persisted; - * - scrub the legacy config-backed Settings gist token: that PAT channel is - * retired (nothing writes or reads it anymore) but older versions persisted - * it, so wipe any leftover copy here too (app boot also purges it). - * - * Never throws — each cleanup is best-effort and logged. + * Disconnect the current BFF session and scrub every legacy browser token sink. + * Local state is cleared first so a network failure can never leave the UI + * connected. The remote session revocation is best-effort and never exposes the + * GitHub token to this module. */ export function disconnectGithub () { - githubAuth.clearToken() - try { window.localStorage.removeItem('tronide.github.token') } catch (e) { console.debug('[githubConnection] clear ls token', e) } - try { window.localStorage.removeItem('tronide.github.user') } catch (e) { console.debug('[githubConnection] clear ls user', e) } - // clearToken owns the current session entries. Repeat the removals only as a - // defensive cleanup if an older/broken store implementation left a copy. - try { window.sessionStorage.removeItem('tronide.github.token') } catch (e) { console.debug('[githubConnection] clear ss token', e) } - try { window.sessionStorage.removeItem('tronide.github.user') } catch (e) { console.debug('[githubConnection] clear ss user', e) } - try { globalRegistry.get('config').api.set('settings/gist-access-token', '') } catch (e) { console.debug('[githubConnection] clear settings gist token', e) } + const session = githubAuth.getSession() + githubAuth.clearSession() + + try { window.localStorage.removeItem('tronide.github.token') } catch (error) { console.debug('[githubConnection] clear ls token', error) } + try { window.localStorage.removeItem('tronide.github.user') } catch (error) { console.debug('[githubConnection] clear ls user', error) } + try { window.localStorage.removeItem('tronide.github.session') } catch (error) { console.debug('[githubConnection] clear ls session', error) } + try { window.sessionStorage.removeItem('tronide.github.token') } catch (error) { console.debug('[githubConnection] clear ss token', error) } + try { globalRegistry.get('config').api.set('settings/gist-access-token', '') } catch (error) { console.debug('[githubConnection] clear settings gist token', error) } + + revokeSession(session).catch((error) => { + console.debug('[githubConnection] remote BFF session revocation failed', error) + }) } diff --git a/apps/remix-ide/src/lib/github-oauth.js b/apps/remix-ide/src/lib/github-oauth.js index 83fe36a94..86954809c 100644 --- a/apps/remix-ide/src/lib/github-oauth.js +++ b/apps/remix-ide/src/lib/github-oauth.js @@ -5,76 +5,45 @@ */ /** - * Connect to GitHub via the OAuth authorization popup instead of pasting a PAT. + * Start the server-owned GitHub OAuth flow. * - * Flow (the IDE is static on GitHub Pages, so a tiny Deno proxy does the secret - * exchange — see services/github-oauth): - * 1. open a popup to GitHub's authorize URL (public client_id + state) - * 2. user approves; GitHub redirects the popup to the proxy /callback - * 3. the proxy exchanges code->token and postMessages { token, login, state } - * back to this window - * 4. we verify origin + state and resolve { token, login } - * - * This module is pure: it only obtains the token. Apply it through the existing - * connect path so the gist-token mirror and the header stay consistent, e.g. - * - * import { connectWithGithubOAuth } from '../../lib/github-oauth' - * const { token, login } = await connectWithGithubOAuth() - * saveGithubToken(token) // existing: stores for this tab (lib/github-auth) - * persistGithubUser(login) // existing: header sync + 'tronideGithubConnectionChanged' + * The Deno BFF generates and validates OAuth state + PKCE, exchanges the code, + * verifies /user, encrypts the GitHub token in KV, and postMessages only an + * opaque TronIDE session handle. The browser never receives a GitHub token. */ +import { assertBffReady, GITHUB_BFF } from './github-bff' + export const GITHUB_OAUTH = { - // Public — the OAuth App client id (safe to ship; the secret lives in the Deno proxy). - clientId: 'Ov23liQFiVI9mMjBfAVK', - // The Deno proxy origin and its /callback (must equal the OAuth App callback URL). - proxyOrigin: 'https://tronide-gh-oauth.redchar1992.deno.net', - get redirectUri () { return this.proxyOrigin + '/callback' }, - authorizeUrl: 'https://github.com/login/oauth/authorize', - // gist = read/write gists; repo = the local Git panel commit/push. - // KEEP `repo` (do not narrow to `public_repo`): the just-shipped remote-git - // feature (dgitProvider clone/push/pull, gitOnAuth) supports PRIVATE repos, and - // `public_repo` cannot push to them. The tab-scoped token store avoids - // persistent localStorage/config rather than narrowing the OAuth scope; - // tightening scope to fine-grained/auto-expiring tokens is Stage 2 - // (GitHub App installation tokens), not this change. - scope: 'gist repo' + proxyOrigin: GITHUB_BFF.origin, + get startUrl () { return this.proxyOrigin + '/oauth/start' } } -function randomState () { - const a = new Uint8Array(16) - ;(window.crypto || window.msCrypto).getRandomValues(a) - return Array.from(a, (b) => b.toString(16).padStart(2, '0')).join('') +function randomChannel () { + const bytes = new Uint8Array(24) + ;(window.crypto || window.msCrypto).getRandomValues(bytes) + return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('') } /** - * @param {object} [opts] - * @param {string} [opts.scope] override the requested scopes - * @returns {Promise<{ token: string, login: string }>} + * @returns {Promise<{ session: string, login: string, userId: number, expiresAt: number }>} */ -export function connectWithGithubOAuth (opts = {}) { +export function connectWithGithubOAuth () { return new Promise((resolve, reject) => { - if (!GITHUB_OAUTH.clientId || GITHUB_OAUTH.clientId.startsWith('<')) { - return reject(new Error('GitHub OAuth is not configured (missing client id).')) - } - - const state = randomState() - const authorize = `${GITHUB_OAUTH.authorizeUrl}?` + new URLSearchParams({ - client_id: GITHUB_OAUTH.clientId, - redirect_uri: GITHUB_OAUTH.redirectUri, - scope: opts.scope || GITHUB_OAUTH.scope, - state, - allow_signup: 'false' + const channel = randomChannel() + const start = `${GITHUB_OAUTH.startUrl}?` + new URLSearchParams({ + origin: window.location.origin, + channel }).toString() - const w = 720 - const h = 720 - const left = window.screenX + Math.max(0, (window.outerWidth - w) / 2) - const top = window.screenY + Math.max(0, (window.outerHeight - h) / 2) - // A per-attempt name prevents an unrelated pre-existing named window from - // being reused as the OAuth browsing context. - const popup = window.open(authorize, 'tronide-github-oauth-' + state, - `width=${w},height=${h},left=${left},top=${top},resizable,scrollbars`) + const width = 720 + const height = 720 + const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2) + const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2) + // Open synchronously to satisfy popup blockers, but do not send users to a + // legacy proxy that would expose a GitHub token to the browser. + const popup = window.open('about:blank', 'tronide-github-oauth-' + channel, + `width=${width},height=${height},left=${left},top=${top},resizable,scrollbars`) if (!popup) return reject(new Error('Popup blocked — allow popups for this site, then try again.')) let settled = false @@ -83,26 +52,43 @@ export function connectWithGithubOAuth (opts = {}) { clearInterval(closedTimer) clearTimeout(hardTimeout) } - const finish = (fn, arg) => { if (!settled) { settled = true; cleanup(); try { popup.close() } catch (e) {} fn(arg) } } - + const finish = (fn, arg) => { + if (settled) return + settled = true + cleanup() + try { popup.close() } catch (error) { console.debug('[githubOAuth] popup close failed', error) } + fn(arg) + } const onMessage = (event) => { - if (event.origin !== GITHUB_OAUTH.proxyOrigin) return - if (event.source !== popup) return - const d = event.data - if (!d || d.source !== 'tronide-github-oauth') return - if (d.state !== state) return finish(reject, new Error('GitHub OAuth state mismatch — aborted.')) - if (d.error) return finish(reject, new Error('GitHub authorization failed: ' + d.error)) - if (!d.token) return finish(reject, new Error('GitHub authorization returned no token.')) - finish(resolve, { token: d.token, login: d.login || '' }) + if (event.origin !== GITHUB_OAUTH.proxyOrigin || event.source !== popup) return + const data = event.data + if (!data || data.source !== 'tronide-github-oauth' || data.channel !== channel) return + if (data.error) return finish(reject, new Error('GitHub authorization failed: ' + data.error)) + if (!/^[A-Za-z0-9_-]{43}$/.test(String(data.session || ''))) { + return finish(reject, new Error('GitHub authorization returned an invalid BFF session.')) + } + if (!data.login || !Number.isSafeInteger(Number(data.userId)) || Number(data.userId) <= 0) { + return finish(reject, new Error('GitHub authorization returned an unverified identity.')) + } + finish(resolve, { + session: String(data.session), + login: String(data.login || ''), + userId: Number(data.userId || 0), + expiresAt: Number(data.expiresAt || 0) + }) } window.addEventListener('message', onMessage) - // The user closed the popup without finishing. const closedTimer = setInterval(() => { if (popup.closed) finish(reject, new Error('GitHub connection cancelled.')) }, 500) - - // Safety net so we never hang forever. const hardTimeout = setTimeout(() => finish(reject, new Error('GitHub connection timed out.')), 120000) + + assertBffReady() + .then(() => { + if (popup.closed) return finish(reject, new Error('GitHub connection cancelled.')) + popup.location.replace(start) + }) + .catch((error) => finish(reject, error)) }) } diff --git a/apps/remix-ide/test/audit-20260527-remediation-test.js b/apps/remix-ide/test/audit-20260527-remediation-test.js index 47ee2e227..ac47a8925 100644 --- a/apps/remix-ide/test/audit-20260527-remediation-test.js +++ b/apps/remix-ide/test/audit-20260527-remediation-test.js @@ -20,24 +20,26 @@ function pathExists (relativePath) { return fs.existsSync(path.join(__dirname, '..', '..', '..', relativePath)) } -// The token is tab-scoped: sessionStorage supplies refresh continuity, while -// localStorage/config remain forbidden so the credential does not outlive the -// tab. These assertions pin both sides of that contract. -test('GitHub token survives refresh only in the current tab', function (t) { +// The browser keeps only a TronIDE BFF session handle. GitHub's access token +// stays encrypted server-side and must never enter frontend storage or state. +test('GitHub access uses only an opaque tab-scoped BFF session', function (t) { const source = readIdeSource('app/ui/landing-page/landing-page.js') const authSource = readIdeSource('lib/github-auth.js') + const bffSource = readIdeSource('lib/github-bff.js') + const oauthSource = readIdeSource('lib/github-oauth.js') t.notOk(/localStorage\.setItem\('tronide\.github\.token'/.test(source + authSource), 'GitHub token is never written to localStorage') - t.notOk(/localStorage\.setItem\('tronide\.github\.user'/.test(source + authSource), 'GitHub user metadata is never written to localStorage') - t.ok(/sessionStorage\.setItem\(key, value\)/.test(authSource), 'GitHub credentials are mirrored to the current tab session') - t.ok(/sessionStorage\.getItem\(key\)/.test(authSource), 'GitHub credentials are rehydrated after a refresh') - t.ok(/githubAuth\.getToken\(\)/.test(source), 'render mirrors the authoritative tab-session token store (lib/github-auth)') - t.notOk(/sessionStorage\.removeItem\('tronide\.github\.token'\)/.test(source), 'Home startup does not erase the refreshed session token') - t.notOk(/sessionStorage\.removeItem\('tronide\.github\.user'\)/.test(source), 'Home startup does not erase the refreshed session login') + t.notOk(/const TOKEN_KEY|function getToken|function setToken/.test(authSource), 'frontend token store API is removed') + t.ok(/const SESSION_KEY = 'tronide\.github\.session'/.test(authSource), 'only a TronIDE BFF session key is persisted') + t.ok(/githubAuth\.isConnected\(\)/.test(source), 'Home renders from BFF session state') + t.ok(/githubBffRequest\(path, options\)/.test(source), 'Home routes GitHub REST calls through the BFF') + t.notOk(/https:\/\/api\.github\.com/.test(source), 'Home never calls GitHub REST directly') + t.ok(/X-TronIDE-Session/.test(bffSource), 'BFF requests use the opaque TronIDE session header') + t.notOk(/Authorization|Bearer/.test(bffSource), 'BFF client never creates a GitHub Authorization header') + t.notOk(/clientId|authorizeUrl|scope:/.test(oauthSource), 'OAuth client id, scopes, and authorize URL are server-owned') + t.notOk(/d\.token|\{ token/.test(oauthSource), 'OAuth popup never consumes or resolves a GitHub token') t.ok(/localStorage\.removeItem\('tronide\.github\.token'\)/.test(source), 'startup and disconnect scrub the legacy localStorage token entry') - t.ok(/localStorage\.removeItem\('tronide\.github\.user'\)/.test(source), 'startup and disconnect scrub the legacy localStorage user entry') - t.notOk(/id="githubTokenRemember"/.test(source), 'the "Remember in this browser" checkbox has been removed from the Connect Token modal') - t.ok(/Tokens stay in this browser tab, survive a refresh/.test(source), 'Connect Token modal advertises refresh-safe tab-only storage') + t.notOk(/Connect token \(PAT\)|promptPassphrase\('Connect with a GitHub token'/.test(source), 'browser PAT entry is removed') t.ok(/sanitizeGithubError/.test(source), 'GitHub error messages flow through a sanitizer before reaching the UI') t.ok(/\[redacted\]/.test(source), 'sanitizer redacts token-shaped substrings') t.end() @@ -100,7 +102,8 @@ test('patched vulnerable dependencies are pinned in package.json and gist handle t.ok(/qs@6\.15\.2:/.test(lockfile), 'lockfile resolves qs@6.15.2') t.ok(/tmp@0\.2\.7:/.test(lockfile), 'lockfile resolves tmp@0.2.7') t.notOk(/require\(['"]request['"]\)/.test(handlerSource), 'gist-handler.js no longer imports the deprecated request module') - t.ok(/window\.fetch/.test(handlerSource), 'gist-handler.js fetches gists via window.fetch') + t.ok(/githubBff\.githubRequest/.test(handlerSource), 'authenticated gist requests use the BFF') + t.ok(/window\.fetch/.test(handlerSource), 'anonymous and raw gist content still use window.fetch') t.ok(/redirect:\s*'error'/.test(handlerSource), 'gist-handler.js disables cross-host redirects so CVE-2023-28155-style SSRF is not reachable through this path') t.end() }) diff --git a/apps/remix-ide/test/audit-20260721-remediation-test.js b/apps/remix-ide/test/audit-20260721-remediation-test.js index 8a0e6363e..3de47088a 100644 --- a/apps/remix-ide/test/audit-20260721-remediation-test.js +++ b/apps/remix-ide/test/audit-20260721-remediation-test.js @@ -31,11 +31,17 @@ test('URL imports, wallet events, OAuth messages, and AI staging keep their secu var filePanel = read('apps/remix-ide/src/app/panels/file-panel.js') var wallet = read('libs/remix-ui/top-header/src/lib/top-header.js') var oauth = read('apps/remix-ide/src/lib/github-oauth.js') + var githubBff = read('apps/remix-ide/src/lib/github-bff.js') + var bff = read('services/github-oauth/main.ts') var chat = read('libs/remix-code-reader/src/components/Chat/index.js') t.ok(filePanel.indexOf('normalizeUrlImport(params.url)') !== -1, '#url is allow-listed before contentImport.resolve') t.ok(wallet.indexOf('event.source !== window || event.origin !== window.location.origin') !== -1, 'wallet postMessage requires the same window and origin') t.ok(oauth.indexOf('event.source !== popup') !== -1, 'OAuth completion must come from the popup that was opened') + t.ok(bff.indexOf('prompt: "select_account"') !== -1, 'server-owned OAuth request must let users choose the GitHub account explicitly') + t.ok(bff.indexOf('code_challenge_method: "S256"') !== -1, 'server-owned OAuth request must use PKCE') + t.equal(oauth.indexOf('access_token'), -1, 'frontend OAuth code must never receive a GitHub access token') + t.ok(oauth.indexOf('assertBffReady()') !== -1 && githubBff.indexOf("authMode !== 'bff-v1'") !== -1, 'frontend fails closed instead of falling back to the legacy token-returning proxy') t.ok(chat.indexOf("title: 'AI wants to stage all workspace changes'") !== -1, 'git_stage_all asks for confirmation') t.ok(chat.indexOf("title: 'AI wants to stage workspace files'") !== -1, 'git_stage asks for confirmation') t.end() diff --git a/apps/remix-ide/test/gist-handler-test.js b/apps/remix-ide/test/gist-handler-test.js index de8bef92b..725ece9d8 100644 --- a/apps/remix-ide/test/gist-handler-test.js +++ b/apps/remix-ide/test/gist-handler-test.js @@ -191,33 +191,35 @@ test('GistHandler.loadFromGist strips .deps/ files baked into a gist', function handler.loadFromGist({ gist: GID }, fileManager) }) -// Regression for "all raw gist files fail with a CORS error even with a token configured": +// Regression for "all raw gist files fail with a CORS error even when connected": // gist.githubusercontent.com only allows CORS-simple GETs, so the raw_url backfill must NOT -// send an Authorization header (that forces an unanswered preflight). The token still goes to -// api.github.com to lift the rate limit. We stub a configured token via the registry and assert -// the raw request is sent header-less while the API request carries the token. +// send the BFF session header (that forces an unanswered preflight). Authenticated gist metadata +// goes through the BFF, while the raw request remains header-less. test('GistHandler.loadFromGist does not send Authorization on raw_url fetches (CORS-simple)', function (t) { t.plan(3) var GID = 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7' var BIG = 'contract Big { uint256 b = 2; }' - githubAuth.setToken('ghp_testtoken1234567890') + githubAuth.setSession('opaque_test_session', 'tron-user') - var apiAuth = 'MISSING' + var apiSession = 'MISSING' var rawAuth = 'MISSING' global.window = { fetch: function (url, opts) { var u = String(url) var headers = (opts && opts.headers) || {} - if (u.indexOf('api.github.com/gists/') !== -1) { - apiAuth = headers.Authorization + var readHeader = function (name) { + return headers && typeof headers.get === 'function' ? headers.get(name) : headers[name] + } + if (u.indexOf('tronide-gh-oauth.redchar1992.deno.net/api/gists/') !== -1) { + apiSession = readHeader('X-TronIDE-Session') var payload = JSON.stringify({ id: GID, files: { 'Big.sol': { truncated: true, content: '', raw_url: 'https://gist.githubusercontent.com/raw/' + GID + '/Big.sol' } } }) return Promise.resolve({ ok: true, status: 200, text: function () { return Promise.resolve(payload) } }) } - rawAuth = headers.Authorization + rawAuth = readHeader('Authorization') return Promise.resolve({ ok: true, status: 200, text: function () { return Promise.resolve(BIG) } }) } } @@ -226,11 +228,11 @@ test('GistHandler.loadFromGist does not send Authorization on raw_url fetches (C captureWorkspaceMutationContext: function () { return { workspace: 'gist-sample', generation: 1 } }, getProvider: function () { return { lastLoadedGistId: null } }, setBatchFiles: function (_obj, _ws, _override, cb) { - t.equal(apiAuth, 'token ghp_testtoken1234567890', 'api.github.com request carries the token') + t.equal(apiSession, 'opaque_test_session', 'BFF gist request carries only the opaque session') t.equal(rawAuth, undefined, 'raw_url request sends NO Authorization header (stays CORS-simple)') t.equal(_obj && Object.keys(_obj).length, 1, 'truncated file still backfilled from raw_url') delete global.window - githubAuth.clearToken() + githubAuth.clearSession() if (cb) cb() } } diff --git a/apps/remix-ide/test/remix-220-home-parity-test.js b/apps/remix-ide/test/remix-220-home-parity-test.js index fd414e509..1bff5f6b0 100644 --- a/apps/remix-ide/test/remix-220-home-parity-test.js +++ b/apps/remix-ide/test/remix-220-home-parity-test.js @@ -81,8 +81,8 @@ test('Remix 2.2.0 home parity records edge, security, accessibility, and blocked var landingSource = fs.readFileSync(landingSourcePath, 'utf8') var contractVerificationSource = fs.readFileSync(contractVerificationSourcePath, 'utf8') - t.ok(landingSource.indexOf('connectWithGithubOAuth') !== -1 && landingSource.indexOf('fine-grained GitHub token') !== -1, 'GitHub OAuth and token-mode boundaries are explicit') - t.ok(landingSource.indexOf('The token stays in this tab, survives refresh') !== -1 && landingSource.indexOf('never written to persistent browser storage') !== -1, 'account auth documents tab-scoped credential storage instead of a nonexistent remember mode') + t.ok(landingSource.indexOf('connectWithGithubOAuth') !== -1 && landingSource.indexOf('GitHub authorization is handled by the TronIDE BFF') !== -1, 'GitHub OAuth and the BFF credential boundary are explicit') + t.ok(landingSource.indexOf('never receives or stores the GitHub access token') !== -1, 'account auth documents the server-side GitHub credential boundary') t.equal(landingSource.indexOf('landingAiAudioButton'), -1, 'Home no longer exposes an AI audio placeholder hook') t.equal(landingSource.indexOf('landingAiModelSelector'), -1, 'Home no longer exposes an AI model selector placeholder hook') t.equal(landingSource.indexOf('landingAiHistoryButton'), -1, 'Home no longer exposes an AI history placeholder hook') diff --git a/apps/remix-ide/webpack.config.js b/apps/remix-ide/webpack.config.js index 14f152aab..e4695fdcd 100644 --- a/apps/remix-ide/webpack.config.js +++ b/apps/remix-ide/webpack.config.js @@ -169,6 +169,9 @@ module.exports = config => { // unless a global `BROWSER` is defined, in which case it uses the bundled // tokens. Define it so the parser works in the browser (in-editor linter). BROWSER: JSON.stringify(true), + // Public team-owned Deno BFF origin. Keep overridable so test and + // production can cut over independently without editing application code. + 'process.env.TRONIDE_GITHUB_BFF_ORIGIN': JSON.stringify(process.env.TRONIDE_GITHUB_BFF_ORIGIN || 'https://tronide-gh-oauth.redchar1992.deno.net'), 'process.env.TRON_PUBLIC_TRONGRID_API_KEY': JSON.stringify(process.env.TRON_PUBLIC_TRONGRID_API_KEY || ''), 'process.env.TRONSCAN_MAINNET_CONTRACT_API_URLS': JSON.stringify(process.env.TRONSCAN_MAINNET_CONTRACT_API_URLS || ''), 'process.env.TRONSCAN_NILE_CONTRACT_API_URLS': JSON.stringify(process.env.TRONSCAN_NILE_CONTRACT_API_URLS || ''), diff --git a/libs/remix-ui/file-explorer/src/lib/file-explorer.tsx b/libs/remix-ui/file-explorer/src/lib/file-explorer.tsx index 7d083c26b..b47b34ab6 100644 --- a/libs/remix-ui/file-explorer/src/lib/file-explorer.tsx +++ b/libs/remix-ui/file-explorer/src/lib/file-explorer.tsx @@ -22,7 +22,6 @@ import React, { useEffect, useState, useRef, useReducer } from 'react'; // eslin import { TreeView, TreeViewItem } from '@remix-ui/tree-view'; // eslint-disable-line import { ModalDialog } from '@remix-ui/modal-dialog'; // eslint-disable-line import { Toaster } from '@remix-ui/toaster'; // eslint-disable-line -import Gists from 'gists' import { FileExplorerMenu } from './file-explorer-menu'; // eslint-disable-line import { FileExplorerContextMenu } from './file-explorer-context-menu'; // eslint-disable-line import { FileExplorerProps, File, MenuItems } from './types' @@ -40,6 +39,7 @@ import { import * as helper from '../../../../../apps/remix-ide/src/lib/helper' import QueryParams from '../../../../../apps/remix-ide/src/lib/query-params' import * as githubAuth from '../../../../../apps/remix-ide/src/lib/github-auth' +import { githubRequest } from '../../../../../apps/remix-ide/src/lib/github-bff' import { customAction } from '@remixproject/plugin-api' import './css/file-explorer.css' @@ -865,16 +865,12 @@ export const FileExplorer = (props: FileExplorerProps) => { * This function is to get the original content of given gist * @params id is the gist id to fetch */ - const getOriginalFiles = async (id, accessToken?) => { + const getOriginalFiles = async (id) => { if (!id) { return [] } - const url = `https://api.github.com/gists/${id}` - // Authenticate with the configured gist token so this update-path read shares the higher - // GitHub rate limit (anonymous reads were hitting "API rate limit exceeded"). The caller - // only reaches here once a token is present, but stay safe if it is ever called without one. - const res = await fetch(url, accessToken ? { headers: { Authorization: `token ${accessToken}` } } : undefined) + const res = await githubRequest(`/gists/${id}`) // A 404 (gist does not exist) or 401/403 (no permission) still returns a JSON body, but it is a // GitHub *error* object with no `files` key. Parsing it and falling back to `data.files || []` // silently yields `[]`, which the update flow then treats as a successful empty read and the @@ -900,12 +896,9 @@ export const FileExplorer = (props: FileExplorerProps) => { async () => {} ) } else { - // Publishing needs the in-memory GitHub connect token (lib/github-auth, set - // by the Home/Header "Connect GitHub" flow). The legacy Settings-tab PAT - // channel is retired — tokens are tab-scoped and never read from config. - const accessToken = (githubAuth.getToken() || '').trim() - - if (!accessToken) { + // Publishing is authenticated by the opaque BFF session. GitHub's token + // is never available to this component or the gists payload. + if (!githubAuth.isConnected()) { modal( 'Connect GitHub', 'Publishing a gist needs a GitHub connection that can create gists. Use "Connect GitHub" on the Home page (or the header button) to sign in, then publish again.', @@ -921,11 +914,9 @@ export const FileExplorer = (props: FileExplorerProps) => { '&runs=' + queryParams.get().runs + '&gist=' - const gists = new Gists({ token: accessToken }) - if (id) { try { - const originalFileList = await getOriginalFiles(id, accessToken) + const originalFileList = await getOriginalFiles(id) // Telling the GIST API to remove files const updatedFileList = Object.keys(packaged) const allItems = Object.keys(originalFileList) @@ -945,18 +936,21 @@ export const FileExplorer = (props: FileExplorerProps) => { }) toast('Saving gist (' + id + ') ...') - await gists.edit(id, - { + const response = await githubRequest(`/gists/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: description, public: true, files: allItems - } - ).then((result) => { - proccedResult(null, result.body) - for (const key in allItems) { - if (allItems[key] === null) delete allItems[key] - } + }) }) + const result = await response.json() + if (!response.ok) throw Object.assign(new Error(result.message || `GitHub gist update failed (${response.status})`), { status: response.status, body: result }) + proccedResult(null, result) + for (const key in allItems) { + if (allItems[key] === null) delete allItems[key] + } } catch (error) { // Clear the "Saving gist..." toast so it does not linger next to the error modal, // then surface a clear failure (e.g. the gist id does not exist or token lacks access) @@ -967,14 +961,18 @@ export const FileExplorer = (props: FileExplorerProps) => { } else { // id is not existing, need to create a new gist toast('Creating a new gist ...') - gists.create( - { + githubRequest('/gists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: description, public: true, files: packaged - } - ).then(result => { - proccedResult(null, result.body) + }) + }).then(async response => { + const result = await response.json() + if (!response.ok) throw Object.assign(new Error(result.message || `GitHub gist creation failed (${response.status})`), { status: response.status, body: result }) + proccedResult(null, result) }).catch(error => { // Clear the "Creating a new gist..." toast first so it does not linger // behind the error modal (avoids a stacked toast + modal). diff --git a/libs/remix-ui/top-header/src/lib/top-header.js b/libs/remix-ui/top-header/src/lib/top-header.js index 0c7d88213..1ba567dde 100644 --- a/libs/remix-ui/top-header/src/lib/top-header.js +++ b/libs/remix-ui/top-header/src/lib/top-header.js @@ -182,12 +182,10 @@ export const TopHeader = ({ plugin, _deps }) => { useEffect(() => { // Reflect the GitHub connection (made on the Home panel) in the header - // button — mirrors the wallet header. The token lives in this tab's session - // (lib/github-auth); read it from there and subscribe to its onChange. - // setToken/clearToken also dispatch 'tronideGithubConnectionChanged', so we - // keep that listener (plus focus) for any consumer that relies on the event. - // A refresh rehydrates the tab-session token; closing the tab forgets it. - const readGithub = () => ({ connected: !!githubAuth.getToken(), login: githubAuth.getLogin() }) + // button — mirrors the wallet header. Only an opaque BFF session handle + // lives in this tab; GitHub's access token remains encrypted server-side. + // Keep the existing event plus the direct store subscription in sync. + const readGithub = () => ({ connected: githubAuth.isConnected(), login: githubAuth.getLogin() }) const refresh = () => setGithubState(readGithub()) refresh() githubAuth.onChange(refresh) @@ -296,7 +294,7 @@ export const TopHeader = ({ plugin, _deps }) => { const onGithubDisconnect = () => { disconnectGithub() setGithubMenuOpen(false) - // githubAuth.clearToken() (inside disconnectGithub) notifies our own + // githubAuth.clearSession() (inside disconnectGithub) notifies our own // subscriber, but set state here too so the label flips without waiting. setGithubState({ connected: false, login: '' }) } diff --git a/services/github-oauth/BFF_MIGRATION.md b/services/github-oauth/BFF_MIGRATION.md new file mode 100644 index 000000000..bbcfaed65 --- /dev/null +++ b/services/github-oauth/BFF_MIGRATION.md @@ -0,0 +1,108 @@ +# GitHub OAuth BFF migration plan (`release/v2.3.3`) + +## Problem + +The legacy flow completes the confidential OAuth exchange in Deno, then sends +the GitHub access token back to the browser. TronIDE stores that token in +`sessionStorage` and sends it to GitHub REST and Git smart-HTTP. The exchange is +server-side, but the credential boundary is still client-side. + +The target architecture makes Deno the GitHub backend-for-frontend (BFF): + +```mermaid +sequenceDiagram + participant UI as TronIDE browser + participant BFF as Deno GitHub BFF + participant GH as GitHub + UI->>BFF: GET /oauth/start (origin + channel) + BFF->>BFF: create state + PKCE verifier (single-use, 10 min) + BFF->>GH: authorize (state + PKCE + select_account) + GH->>BFF: /callback (code + state) + BFF->>BFF: atomically consume state + BFF->>GH: exchange code + verify /user + BFF->>BFF: encrypt token in KV; create scoped session + BFF-->>UI: postMessage(session handle + login; never GitHub token) + UI->>BFF: /api/* or /git/* + session handle + BFF->>GH: allow-listed request + server-held GitHub token +``` + +## Security and API contract + +- Deno generates OAuth `state` and the PKCE verifier. State is atomically + consumed from KV and expires after 10 minutes. +- `prompt=select_account` remains a server-owned authorization parameter. +- The callback validates the GitHub identity through `GET /user` before a + session is issued. +- GitHub tokens are AES-GCM encrypted in Deno KV. The KV key is a SHA-256 hash + of the random TronIDE session handle, so neither the raw handle nor token is + persisted. +- The callback sends only an opaque, revocable TronIDE session handle and the + verified login to the opener. It is bound to the exact requesting origin and + has an eight-hour default lifetime. +- The browser keeps that handle in tab-scoped `sessionStorage`; it is not a + GitHub credential and is accepted only by this BFF, its allow-listed routes, + and the origin that created it. +- `GET /session` validates/hydrates a session. `DELETE /session` revokes the + local session and best-effort revokes the upstream OAuth token. +- `/api/*` exposes only the REST operations TronIDE uses (`/user`, repository + contents, and gists). It cannot act as an arbitrary GitHub proxy. +- `/git/*` remains pinned to GitHub smart-HTTP paths. Incoming browser + `Authorization` headers are rejected; the BFF injects upstream credentials + only after validating `X-TronIDE-Session`. +- Requests are origin-checked, rate-limited, redirect-disabled, and protected by + no-store and browser security headers. + +## Implementation order + +1. Add the Deno state/session store, token encryption, OAuth start/callback, + session, restricted REST, and authenticated Git proxy endpoints. +2. Replace the browser token store with the opaque BFF session store. +3. Route landing-page repository calls, gist calls, and Git smart-HTTP auth + through the BFF; remove the browser PAT flow. +4. Add server unit tests, frontend security-contract tests, and update browser + tests to seed/mock a BFF session instead of a GitHub token. +5. Run formatting/linting, focused tests, core tests, and a production build. + +## Deployment and cut-over + +The GitLab frontend pipeline does **not** deploy `services/github-oauth`; Deno +must be deployed separately. Do not point production at the BFF frontend until +the service and secrets below are ready. + +1. Complete ownership transfer of both the GitHub OAuth App and the Deno project + to the `tronweb3` team. Repository ownership alone does not transfer the Deno + project or its secrets. +2. Attach Deno KV and configure: + - `GITHUB_CLIENT_ID` + - `GITHUB_CLIENT_SECRET` + - `SESSION_ENCRYPTION_KEY` (32 random bytes, base64 encoded) + - `REDIRECT_URI` + - `ALLOWED_ORIGINS` (production and test origins) + - optional rate/session lifetime variables documented in `README.md` +3. Deploy this service source and verify `/health` reports `bff-v1` and + `/oauth/start` redirects with `state`, `code_challenge`, and + `prompt=select_account`. +4. Update the GitHub OAuth App callback to the team-owned BFF `/callback`. +5. Set the frontend build variable `TRONIDE_GITHUB_BFF_ORIGIN` to the same + team-owned BFF origin, then deploy `release/v2.3.3` to the test environment; + run connect, refresh, gist, public/private repository import, + commit/push/pull, disconnect, and expiry checks. Confirm DevTools never + contains a GitHub access token. +6. Promote the same pair (BFF, then frontend) to production. + +If the BFF is unavailable, fail closed by disabling GitHub connect; never fall +back to returning a GitHub token to the browser. Roll back the frontend and BFF +as a pair, or leave GitHub connect temporarily unavailable while preserving the +rest of TronIDE. + +## Acceptance criteria + +- No GitHub access token appears in callback HTML, `postMessage`, web storage, + frontend state, browser request headers, logs, or error messages. +- Replayed/expired OAuth state and replayed/revoked/expired sessions fail. +- A session issued for one allowed origin fails from every other origin. +- REST and Git proxy path traversal, arbitrary hosts, redirects, and raw browser + credentials are rejected. +- Account selection is shown on every new OAuth connection. +- Test-environment build SHA matches the pushed `release/v2.3.3` SHA and all + required GitLab jobs pass. diff --git a/services/github-oauth/README.md b/services/github-oauth/README.md index 96a18abfd..b91ef56b0 100644 --- a/services/github-oauth/README.md +++ b/services/github-oauth/README.md @@ -1,67 +1,99 @@ -# tronide-gh-oauth — GitHub OAuth proxy for the static IDE +# tronide-gh-oauth — GitHub OAuth BFF for TronIDE -GitHub Pages is static, so it can't hold the OAuth client secret or call GitHub's -token endpoint (no CORS). This one-file Deno Deploy function is the only -server-side piece. The IDE opens a GitHub authorization popup; this function -exchanges the returned `code` for an access token and posts it back to the -opener. +TronIDE is a static application, so its GitHub credential boundary lives in this +Deno backend-for-frontend (BFF). Deno owns OAuth state and PKCE, exchanges the +code, verifies the GitHub identity, encrypts the access token in KV, and returns +only a short-lived TronIDE session handle to the browser. -## 1. Register the GitHub OAuth App +The browser uses that opaque session for restricted GitHub REST and Git +smart-HTTP endpoints. A GitHub token is never sent through `postMessage`, web +storage, frontend state, or browser request headers. -https://github.com/settings/applications/new +The frontend BFF origin is public build configuration: +`TRONIDE_GITHUB_BFF_ORIGIN`. Set it to the team-owned deployment during +cut-over; no source edit is required when the Deno project/domain changes. -| Field | Value | -| --- | --- | -| Application name | `TronIDE` | -| Homepage URL | `https://tronide.io` | -| Authorization callback URL | `https://tronide-gh-oauth.redchar1992.deno.net/callback` | -| Enable Device Flow | off | +See [`BFF_MIGRATION.md`](./BFF_MIGRATION.md) for the architecture, rollout, +rollback, and acceptance criteria. -Copy the **Client ID** (goes in the frontend, public). Click **Generate a new -client secret** (goes in Deno env only — never in the frontend). +## 1. GitHub OAuth App -## 2. Deploy to Deno Deploy +Configure the team-owned OAuth App with: + +| Field | Value | +| -------------------------- | -------------------------------------- | +| Application name | `TronIDE` | +| Homepage URL | `https://tronide.io` | +| Authorization callback URL | `` ending in `/callback` | +| Enable Device Flow | off | + +The OAuth App and the Deno project are separate resources. Transfer and verify +both; transferring the source repository alone changes neither one. + +## 2. Deno deployment + +Attach a Deno KV database, then deploy `main.ts`: ```sh -deno install -A jsr:@deno/deployctl --global # once +deno install -A jsr:@deno/deployctl --global cd services/github-oauth deployctl deploy --project=tronide-gh-oauth main.ts ``` -(Or connect this repo in the Deno Deploy dashboard and point it at -`services/github-oauth/main.ts`.) - -## 3. Set environment variables (Deno Deploy → Settings → Environment Variables) - -| Var | Value | -| --- | --- | -| `GITHUB_CLIENT_ID` | the OAuth App client id | -| `GITHUB_CLIENT_SECRET` | the OAuth App client secret | -| `REDIRECT_URI` | `https://tronide-gh-oauth.redchar1992.deno.net/callback` | -| `ALLOWED_ORIGINS` | `https://tronide.io` (add `,https://.github.io` if used) | -| `OAUTH_RATE_LIMIT` | Optional OAuth callback attempts/client/minute (default `10`) | -| `GIT_PUBLIC_RATE_LIMIT` | Optional anonymous Git proxy requests/client/minute (default `30`) | -| `GIT_AUTH_RATE_LIMIT` | Optional authenticated Git proxy requests/client/minute (default `120`) | - -Attach a Deno KV database to the deployment so rate-limit counters are shared -across edge isolates. The service falls back to bounded in-memory counters for -local development or a temporary deployment where KV is unavailable. - -## 4. Wire the frontend - -Set `clientId` (and `proxyOrigin`, if your project URL differs) in -`apps/remix-ide/src/lib/github-oauth.js`, then connect via -`connectWithGithubOAuth()`. See that file's header for the exact wiring. - -## Notes - -- The token is posted to **each** `ALLOWED_ORIGINS` entry; the browser only - delivers a `postMessage` when the opener's origin matches, so this never leaks - the token to an unintended origin. -- `state` is generated in the browser and checked for the expected 32-hex form; - the frontend also verifies the exact value (CSRF). -- OAuth callback and Git smart-HTTP traffic are rate-limited before any GitHub - request. Browser Git requests with a non-allow-listed `Origin` are rejected. -- Local dev: register a second OAuth App with callback - `http://localhost:8080/callback`, run `deno task dev`, and point the frontend - `proxyOrigin` at `http://localhost:8000`. +A linked repository may deploy the same entry point automatically. The main +TronIDE GitLab pipeline does not deploy this service. + +## 3. Environment variables + +| Variable | Required | Description | +| ------------------------ | -------- | ------------------------------------------------------ | +| `GITHUB_CLIENT_ID` | yes | Team-owned GitHub OAuth App client id | +| `GITHUB_CLIENT_SECRET` | yes | OAuth App client secret; Deno only | +| `SESSION_ENCRYPTION_KEY` | yes | Exactly 32 random bytes, base64 encoded | +| `REDIRECT_URI` | yes | Public Deno/team BFF `/callback` URL | +| `ALLOWED_ORIGINS` | yes | Comma-separated exact TronIDE origins | +| `GITHUB_SCOPE` | no | Defaults to `gist repo` | +| `SESSION_TTL_SECONDS` | no | BFF session lifetime; defaults to 8 hours | +| `OAUTH_RATE_LIMIT` | no | OAuth starts/callbacks per client/minute; default `10` | +| `API_RATE_LIMIT` | no | Authenticated REST calls/client/minute; default `120` | +| `GIT_PUBLIC_RATE_LIMIT` | no | Anonymous Git calls/client/minute; default `30` | +| `GIT_AUTH_RATE_LIMIT` | no | Authenticated Git calls/client/minute; default `120` | + +Generate the encryption key without printing or committing it to source: + +```sh +openssl rand -base64 32 +``` + +Store it only in Deno's secret/environment settings. Sessions fail closed when +KV or the encryption key is unavailable; only rate limiting has an in-memory +fallback. + +## 4. Endpoints + +| Endpoint | Purpose | +| ------------------- | ------------------------------------------------------------------- | +| `GET /health` | Reports `mode=bff-v1` | +| `GET /capabilities` | Machine-readable BFF capability probe | +| `GET /oauth/start` | Creates state + PKCE and redirects to GitHub with account selection | +| `GET /callback` | Consumes state, verifies GitHub, creates encrypted server session | +| `GET /session` | Validates and hydrates the current session | +| `DELETE /session` | Revokes the local session and best-effort GitHub token | +| `/api/*` | Allow-listed `/user`, repository contents, and gist operations | +| `/git/*` | GitHub-only smart-HTTP proxy for isomorphic-git | + +Authenticated browser calls send `X-TronIDE-Session`; `Authorization` from the +browser is rejected. Sessions are bound to the exact `Origin` that initiated +OAuth. + +## 5. Local verification + +```sh +cd services/github-oauth +deno task test +deno check --unstable main.ts +``` + +For a full local OAuth flow, create a separate development OAuth App and set its +callback to the local BFF. Never reuse production client secrets in committed +files or test fixtures. diff --git a/services/github-oauth/deno.json b/services/github-oauth/deno.json index 504321fa7..7369ed491 100644 --- a/services/github-oauth/deno.json +++ b/services/github-oauth/deno.json @@ -2,6 +2,7 @@ "tasks": { "dev": "deno run --unstable --allow-net --allow-env --allow-read --allow-write --watch main.ts", "test": "deno test --unstable --allow-env main_test.ts", + "check": "deno fmt --check main.ts main_test.ts && deno check --unstable main.ts main_test.ts", "deploy": "deployctl deploy --project=tronide-gh-oauth main.ts" } } diff --git a/services/github-oauth/main.ts b/services/github-oauth/main.ts index b0c19409d..083d3e628 100644 --- a/services/github-oauth/main.ts +++ b/services/github-oauth/main.ts @@ -1,433 +1,1353 @@ /* - * TronIDE — GitHub OAuth code→token exchange (Deno Deploy). + * TronIDE — GitHub OAuth backend-for-frontend (Deno Deploy). * - * The IDE is a static site on GitHub Pages, so it cannot hold the OAuth client - * secret or call GitHub's token endpoint (no CORS) itself. This single-file - * Deno function is the only server-side piece: it receives the `code` from the - * GitHub authorization popup, exchanges it (with the secret) for an access - * token, resolves the login, and hands both back to the opener via postMessage. - * - * Deploy: deployctl deploy --project=tronide-gh-oauth main.ts - * Callback (register in the GitHub OAuth App): - * https://tronide-gh-oauth.redchar1992.deno.net/callback - * - * Env vars (Deno Deploy → Settings → Environment Variables): - * GITHUB_CLIENT_ID the OAuth App client id - * GITHUB_CLIENT_SECRET the OAuth App client secret (never shipped to the browser) - * REDIRECT_URI https://tronide-gh-oauth.redchar1992.deno.net/callback - * ALLOWED_ORIGINS comma-separated site origins allowed to receive the token, - * e.g. "https://tronide.io,https://.github.io" - * OAUTH_RATE_LIMIT callback attempts per client/minute (default 10) - * GIT_PUBLIC_RATE_LIMIT unauthenticated git requests per client/minute (default 30) - * GIT_AUTH_RATE_LIMIT authenticated git requests per client/minute (default 120) + * The browser never receives a GitHub access token. Deno owns OAuth state and + * PKCE, encrypts the token in KV, and gives the browser only a short-lived, + * origin-bound TronIDE session handle. GitHub REST and smart-HTTP calls are + * allow-listed and authenticated here. */ -const CLIENT_ID = Deno.env.get('GITHUB_CLIENT_ID') ?? '' -const CLIENT_SECRET = Deno.env.get('GITHUB_CLIENT_SECRET') ?? '' -const REDIRECT_URI = Deno.env.get('REDIRECT_URI') ?? 'https://tronide-gh-oauth.redchar1992.deno.net/callback' -const ALLOWED_ORIGINS = (Deno.env.get('ALLOWED_ORIGINS') ?? 'https://tronide.io') - .split(',').map((s) => s.trim()).filter(Boolean) -const OAUTH_RATE_LIMIT = positiveInt(Deno.env.get('OAUTH_RATE_LIMIT'), 10) -const GIT_PUBLIC_RATE_LIMIT = positiveInt(Deno.env.get('GIT_PUBLIC_RATE_LIMIT'), 30) -const GIT_AUTH_RATE_LIMIT = positiveInt(Deno.env.get('GIT_AUTH_RATE_LIMIT'), 120) -const RATE_WINDOW_MS = 60_000 - -type RateRecord = { count: number; resetAt: number } -type RateResult = { allowed: boolean; limit: number; remaining: number; resetAt: number } -type RateCheck = (req: Request, bucket: string, limit: number, windowMs: number, info?: unknown) => RateResult | Promise - -function positiveInt (value: string | null | undefined, fallback: number): number { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback -} - -function peerHostname (info?: unknown): string { - if (!info || typeof info !== 'object' || !('remoteAddr' in info)) return '' - const remoteAddr = (info as { remoteAddr?: unknown }).remoteAddr - if (!remoteAddr || typeof remoteAddr !== 'object' || !('hostname' in remoteAddr)) return '' - const hostname = (remoteAddr as { hostname?: unknown }).hostname - return typeof hostname === 'string' ? hostname : '' -} - -function clientAddress (req: Request, info?: unknown): string { - // Prefer the server-provided peer address, which request authors cannot - // spoof. Deno Deploy also sets these edge headers; use them as the fallback - // when the runtime does not expose remoteAddr to the handler. - const remote = peerHostname(info) - const forwarded = req.headers.get('cf-connecting-ip') || - req.headers.get('x-real-ip') || - (req.headers.get('x-forwarded-for') || '').split(',')[0].trim() - return remote || forwarded || 'unknown' -} - -async function hashClientAddress (address: string): Promise { - const bytes = new TextEncoder().encode(address) - const digest = await crypto.subtle.digest('SHA-256', bytes) - return Array.from(new Uint8Array(digest)).slice(0, 16).map((b) => b.toString(16).padStart(2, '0')).join('') -} - -// Deno KV makes counters consistent across edge isolates. If no database is -// attached (local development or a temporary deployment), the bounded local -// map still provides a per-isolate safety net rather than failing OAuth/Git. -export function createRateLimiter (options: { +const CLIENT_ID = Deno.env.get("GITHUB_CLIENT_ID") ?? ""; +const CLIENT_SECRET = Deno.env.get("GITHUB_CLIENT_SECRET") ?? ""; +const REDIRECT_URI = Deno.env.get("REDIRECT_URI") ?? + "https://tronide-gh-oauth.redchar1992.deno.net/callback"; +const SESSION_ENCRYPTION_KEY = Deno.env.get("SESSION_ENCRYPTION_KEY") ?? ""; +const ALLOWED_ORIGINS = + (Deno.env.get("ALLOWED_ORIGINS") ?? "https://tronide.io") + .split(",").map((value) => value.trim()).filter(Boolean); +const GITHUB_SCOPE = Deno.env.get("GITHUB_SCOPE") ?? "gist repo"; +const OAUTH_RATE_LIMIT = positiveInt(Deno.env.get("OAUTH_RATE_LIMIT"), 10); +const API_RATE_LIMIT = positiveInt(Deno.env.get("API_RATE_LIMIT"), 120); +const GIT_PUBLIC_RATE_LIMIT = positiveInt( + Deno.env.get("GIT_PUBLIC_RATE_LIMIT"), + 30, +); +const GIT_AUTH_RATE_LIMIT = positiveInt( + Deno.env.get("GIT_AUTH_RATE_LIMIT"), + 120, +); +const SESSION_TTL_MS = + positiveInt(Deno.env.get("SESSION_TTL_SECONDS"), 8 * 60 * 60) * 1000; +const OAUTH_ATTEMPT_TTL_MS = 10 * 60 * 1000; +const RATE_WINDOW_MS = 60_000; +const SESSION_HEADER = "x-tronide-session"; +const MAX_API_BODY_BYTES = 5 * 1024 * 1024; +const MAX_GIT_BODY_BYTES = 64 * 1024 * 1024; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +type RateRecord = { count: number; resetAt: number }; +type RateResult = { + allowed: boolean; + limit: number; + remaining: number; + resetAt: number; +}; +type RateCheck = ( + req: Request, + bucket: string, + limit: number, + windowMs: number, + info?: unknown, +) => RateResult | Promise; + +export type OAuthAttempt = { + origin: string; + channel: string; + verifier: string; + expiresAt: number; +}; + +export type StoredSession = { + origin: string; + encryptedToken: string; + login: string; + userId: number; + createdAt: number; + expiresAt: number; +}; + +export interface AuthStore { + saveAttempt(state: string, attempt: OAuthAttempt): Promise; + consumeAttempt(state: string): Promise; + saveSession(handle: string, session: StoredSession): Promise; + getSession(handle: string): Promise; + deleteSession(handle: string): Promise; +} + +export interface TokenCipher { + encrypt(token: string): Promise; + decrypt(ciphertext: string): Promise; +} + +function positiveInt( + value: string | null | undefined, + fallback: number, +): number { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function bytesToBase64Url(bytes: Uint8Array): string { + return bytesToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace( + /=+$/g, + "", + ); +} + +function base64ToBytes(value: string): Uint8Array { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +function randomToken(length = 32): string { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return bytesToBase64Url(bytes); +} + +async function sha256Base64Url(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return bytesToBase64Url(new Uint8Array(digest)); +} + +export async function createTokenCipher(secret: string): Promise { + let keyBytes: Uint8Array; + try { + keyBytes = base64ToBytes(secret); + } catch (_error) { + throw new Error("SESSION_ENCRYPTION_KEY must be base64 encoded"); + } + if (keyBytes.length !== 32) { + throw new Error("SESSION_ENCRYPTION_KEY must decode to exactly 32 bytes"); + } + const key = await crypto.subtle.importKey( + "raw", + keyBytes, + { name: "AES-GCM" }, + false, + ["encrypt", "decrypt"], + ); + + return { + async encrypt(token: string): Promise { + const iv = new Uint8Array(12); + crypto.getRandomValues(iv); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + encoder.encode(token), + ); + return `v1.${bytesToBase64Url(iv)}.${ + bytesToBase64Url(new Uint8Array(encrypted)) + }`; + }, + async decrypt(value: string): Promise { + const [version, ivValue, encryptedValue, extra] = value.split("."); + if ( + version !== "v1" || !ivValue || !encryptedValue || extra !== undefined + ) throw new Error("Invalid encrypted token"); + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(ivValue) }, + key, + base64ToBytes(encryptedValue), + ); + return decoder.decode(decrypted); + }, + }; +} + +export function createMemoryAuthStore( + options: { now?: () => number } = {}, +): AuthStore { + const now = options.now || Date.now; + const attempts = new Map(); + const sessions = new Map(); + + return { + async saveAttempt(state, attempt) { + attempts.set(await sha256Base64Url(state), structuredClone(attempt)); + }, + async consumeAttempt(state) { + const key = await sha256Base64Url(state); + const attempt = attempts.get(key) || null; + attempts.delete(key); + return attempt && attempt.expiresAt > now() + ? structuredClone(attempt) + : null; + }, + async saveSession(handle, session) { + sessions.set(await sha256Base64Url(handle), structuredClone(session)); + }, + async getSession(handle) { + const key = await sha256Base64Url(handle); + const session = sessions.get(key) || null; + if (!session || session.expiresAt <= now()) { + sessions.delete(key); + return null; + } + return structuredClone(session); + }, + async deleteSession(handle) { + sessions.delete(await sha256Base64Url(handle)); + }, + }; +} + +export function createKvAuthStore( + kv: Deno.Kv, + options: { now?: () => number } = {}, +): AuthStore { + const now = options.now || Date.now; + const attemptKey = async ( + state: string, + ): Promise => [ + "github-oauth-attempt-v2", + await sha256Base64Url(state), + ]; + const sessionKey = async ( + handle: string, + ): Promise => [ + "github-session-v2", + await sha256Base64Url(handle), + ]; + + return { + async saveAttempt(state, attempt) { + await (kv.set as unknown as ( + key: Deno.KvKey, + value: unknown, + options?: { expireIn?: number }, + ) => Promise)(await attemptKey(state), attempt, { + expireIn: Math.max(1, attempt.expiresAt - now()), + }); + }, + async consumeAttempt(state) { + const key = await attemptKey(state); + for (let retry = 0; retry < 4; retry++) { + const entry = await kv.get(key); + if (!entry.value) return null; + const committed = await kv.atomic().check(entry).delete(key).commit(); + if (committed.ok) { + return entry.value.expiresAt > now() ? entry.value : null; + } + } + return null; + }, + async saveSession(handle, session) { + await (kv.set as unknown as ( + key: Deno.KvKey, + value: unknown, + options?: { expireIn?: number }, + ) => Promise)(await sessionKey(handle), session, { + expireIn: Math.max(1, session.expiresAt - now()), + }); + }, + async getSession(handle) { + const key = await sessionKey(handle); + const entry = await kv.get(key); + if (!entry.value) return null; + if (entry.value.expiresAt <= now()) { + await kv.delete(key); + return null; + } + return entry.value; + }, + async deleteSession(handle) { + await kv.delete(await sessionKey(handle)); + }, + }; +} + +function peerHostname(info?: unknown): string { + if (!info || typeof info !== "object" || !("remoteAddr" in info)) return ""; + const remoteAddr = (info as { remoteAddr?: unknown }).remoteAddr; + if ( + !remoteAddr || typeof remoteAddr !== "object" || !("hostname" in remoteAddr) + ) return ""; + const hostname = (remoteAddr as { hostname?: unknown }).hostname; + return typeof hostname === "string" ? hostname : ""; +} + +function clientAddress(req: Request, info?: unknown): string { + const remote = peerHostname(info); + const forwarded = req.headers.get("cf-connecting-ip") || + req.headers.get("x-real-ip") || + (req.headers.get("x-forwarded-for") || "").split(",")[0].trim(); + return remote || forwarded || "unknown"; +} + +async function hashClientAddress(address: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(address)); + return Array.from(new Uint8Array(digest)).slice(0, 16) + .map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +// Deno KV makes counters consistent across edge isolates. A bounded in-memory +// fallback is acceptable for throttling, but never for OAuth attempts/sessions. +export function createRateLimiter(options: { now?: () => number; kvFactory?: () => Deno.Kv | null | Promise; } = {}): RateCheck { - const now = options.now || Date.now - const local = new Map() - let kvPromise: Promise | null = null + const now = options.now || Date.now; + const local = new Map(); + let kvPromise: Promise | null = null; const getKv = () => { if (!kvPromise) { - // Invoke the factory inside the async boundary. `Deno.openKv()` can throw - // synchronously when the deployment has no KV database attached (or the - // runtime does not expose the unstable API), which Promise.resolve(value) - // cannot catch because `value` is evaluated first. kvPromise = (async () => { try { - if (options.kvFactory) return await options.kvFactory() - if (typeof Deno.openKv !== 'function') return null - return await Deno.openKv() + if (options.kvFactory) return await options.kvFactory(); + if (typeof Deno.openKv !== "function") return null; + return await Deno.openKv(); } catch (error) { - console.warn('[rate-limit] Deno KV unavailable; using per-isolate counters', error) - return null + console.warn( + "[rate-limit] Deno KV unavailable; using per-isolate counters", + error, + ); + return null; } - })() + })(); } - return kvPromise - } + return kvPromise; + }; return async (req, bucket, limit, windowMs, info) => { - const client = await hashClientAddress(clientAddress(req, info)) - const localKey = bucket + ':' + client - const key: Deno.KvKey = ['rate-limit-v1', bucket, client] - const timestamp = now() - const kv = await getKv() + const client = await hashClientAddress(clientAddress(req, info)); + const localKey = `${bucket}:${client}`; + const key: Deno.KvKey = ["rate-limit-v1", bucket, client]; + const timestamp = now(); + const kv = await getKv(); if (kv) { try { for (let attempt = 0; attempt < 8; attempt++) { - const entry = await kv.get(key) + const entry = await kv.get(key); const current = entry.value && entry.value.resetAt > timestamp ? entry.value - : { count: 0, resetAt: timestamp + windowMs } + : { count: 0, resetAt: timestamp + windowMs }; if (current.count >= limit) { - return { allowed: false, limit, remaining: 0, resetAt: current.resetAt } + return { + allowed: false, + limit, + remaining: 0, + resetAt: current.resetAt, + }; } - const next = { count: current.count + 1, resetAt: current.resetAt } - const committed = await kv.atomic().check(entry).set(key, next).commit() + const next = { count: current.count + 1, resetAt: current.resetAt }; + const committed = await kv.atomic().check(entry).set(key, next) + .commit(); if (committed.ok) { - return { allowed: true, limit, remaining: Math.max(0, limit - next.count), resetAt: next.resetAt } + return { + allowed: true, + limit, + remaining: Math.max(0, limit - next.count), + resetAt: next.resetAt, + }; } } } catch (error) { - console.warn('[rate-limit] Deno KV transaction failed; using per-isolate counters', error) + console.warn( + "[rate-limit] Deno KV transaction failed; using per-isolate counters", + error, + ); } } - const current = local.get(localKey) - const active = current && current.resetAt > timestamp ? current : { count: 0, resetAt: timestamp + windowMs } - if (active.count >= limit) return { allowed: false, limit, remaining: 0, resetAt: active.resetAt } - const next = { count: active.count + 1, resetAt: active.resetAt } - local.set(localKey, next) + const current = local.get(localKey); + const active = current && current.resetAt > timestamp + ? current + : { count: 0, resetAt: timestamp + windowMs }; + if (active.count >= limit) { + return { allowed: false, limit, remaining: 0, resetAt: active.resetAt }; + } + const next = { count: active.count + 1, resetAt: active.resetAt }; + local.set(localKey, next); if (local.size > 10_000) { for (const [entryKey, record] of local) { - if (record.resetAt <= timestamp) local.delete(entryKey) + if (record.resetAt <= timestamp) local.delete(entryKey); } - // Keep the fallback truly bounded even during a burst of unique client - // addresses within one rate window. Map iteration order is insertion - // order, so evict the oldest counters first. while (local.size > 10_000) { - const oldestKey = local.keys().next().value - if (oldestKey === undefined) break - local.delete(oldestKey) + const oldestKey = local.keys().next().value; + if (oldestKey === undefined) break; + local.delete(oldestKey); } } - return { allowed: true, limit, remaining: Math.max(0, limit - next.count), resetAt: next.resetAt } - } + return { + allowed: true, + limit, + remaining: Math.max(0, limit - next.count), + resetAt: next.resetAt, + }; + }; } -const defaultRateCheck = createRateLimiter() +const defaultRateCheck = createRateLimiter(); const SECURITY_HEADERS: Record = { - 'strict-transport-security': 'max-age=31536000', - 'x-content-type-options': 'nosniff', - 'referrer-policy': 'no-referrer' -} + "strict-transport-security": "max-age=31536000", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "permissions-policy": "camera=(), microphone=(), geolocation=()", + "cross-origin-opener-policy": "unsafe-none", +}; -function setSecurityHeaders (headers: Headers): Headers { - for (const [name, value] of Object.entries(SECURITY_HEADERS)) headers.set(name, value) - return headers +function setSecurityHeaders(headers: Headers): Headers { + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + headers.set(name, value); + } + return headers; } -function rateHeaders (result: RateResult): Record { +function rateHeaders(result: RateResult): Record { return { - 'x-ratelimit-limit': String(result.limit), - 'x-ratelimit-remaining': String(result.remaining), - 'x-ratelimit-reset': String(Math.ceil(result.resetAt / 1000)) - } + "x-ratelimit-limit": String(result.limit), + "x-ratelimit-remaining": String(result.remaining), + "x-ratelimit-reset": String(Math.ceil(result.resetAt / 1000)), + }; } -function rateLimitedResponse (result: RateResult, headers: Record = {}): Response { - const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000)) - return new Response('Too many requests', { +function rateLimitedResponse( + result: RateResult, + headers: Record = {}, +): Response { + const retryAfter = Math.max( + 1, + Math.ceil((result.resetAt - Date.now()) / 1000), + ); + return new Response("Too many requests", { status: 429, - headers: setSecurityHeaders(new Headers({ ...headers, ...rateHeaders(result), 'retry-after': String(retryAfter), 'cache-control': 'no-store' })) - }) + headers: setSecurityHeaders( + new Headers({ + ...headers, + ...rateHeaders(result), + "retry-after": String(retryAfter), + "cache-control": "no-store", + }), + ), + }); } -// Neutralize sequences that can break out of a `, `-->`, or the JS line terminators U+2028/U+2029, -// so a reflected value (state / GitHub error) could otherwise inject markup. -function escapeForScript (json: string): string { +function escapeForScript(json: string): string { return json - .replace(//g, '\\u003e') - .replace(/\u2028/g, '\\u2028') - .replace(/\u2029/g, '\\u2029') -} - -// `state` is generated by the browser as hex and only echoed back; reject -// anything else so a crafted value can never be reflected into the page. -function safeState (raw: string | null): string { - return raw && /^[A-Za-z0-9]{8,64}$/.test(raw) ? raw : '' -} - -// Render a tiny page that posts the result to the opener and closes itself. -// We post to EACH allowed origin: the browser only delivers a message when the -// opener's origin matches the targetOrigin, so looping never leaks the token to -// an unintended origin. -function resultPage (payload: Record, status = 200, extraHeaders: Record = {}): Response { - const data = escapeForScript(JSON.stringify({ source: 'tronide-github-oauth', ...payload })) - const origins = escapeForScript(JSON.stringify(ALLOWED_ORIGINS)) - const body = `GitHub + .replace(//g, "\\u003e") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +function resultPage( + targetOrigin: string, + channel: string, + payload: Record, + status = 200, + extraHeaders: Record = {}, +): Response { + const data = escapeForScript( + JSON.stringify({ source: "tronide-github-oauth", channel, ...payload }), + ); + const origin = escapeForScript(JSON.stringify(targetOrigin)); + const nonce = randomToken(18); + const body = + `GitHub - -` +`; + return new Response(body, { + status, + headers: setSecurityHeaders( + new Headers({ + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "content-security-policy": + `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'`, + ...extraHeaders, + }), + ), + }); +} + +function standaloneErrorPage(error: string, status: number): Response { + const body = + `GitHubGitHub connect failed: ${error}. You can close this window.`; return new Response(body, { status, - headers: setSecurityHeaders(new Headers({ - 'content-type': 'text/html; charset=utf-8', - 'cache-control': 'no-store', - 'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'", - ...extraHeaders - })) - }) -} - -// CORS allow-origin for a given request: echo it only when it is allow-listed. -// Never use '*' because git requests can carry an Authorization header. -function corsOrigin (req: Request): string { - const origin = req.headers.get('origin') ?? '' - return ALLOWED_ORIGINS.includes(origin) ? origin : '' -} - -// Headers git's smart-HTTP client may send and needs us to forward/allow. Kept -// lower-case for case-insensitive comparison against the incoming request. -const GIT_ALLOW_HEADERS = [ - 'accept', 'authorization', 'content-type', 'content-length', - 'git-protocol', 'user-agent', 'pragma', 'cache-control', 'x-requested-with' -] -// Response headers git needs to read back from the proxied response. -const GIT_EXPOSE_HEADERS = [ - 'content-type', 'content-length', 'content-encoding', 'transfer-encoding', - 'cache-control', 'expires', 'pragma', 'www-authenticate', - 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset', 'retry-after' -] - -function gitCorsHeaders (req: Request): Record { + headers: setSecurityHeaders( + new Headers({ + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "content-security-policy": "default-src 'none'", + }), + ), + }); +} + +function corsOrigin(req: Request, allowedOrigins: string[]): string { + const origin = req.headers.get("origin") ?? ""; + return allowedOrigins.includes(origin) ? origin : ""; +} + +const BFF_ALLOW_HEADERS = [ + "accept", + "content-type", + "if-none-match", + "git-protocol", + "pragma", + "cache-control", + "x-requested-with", + SESSION_HEADER, +]; +const BFF_EXPOSE_HEADERS = [ + "content-type", + "etag", + "last-modified", + "link", + "www-authenticate", + "x-ratelimit-limit", + "x-ratelimit-remaining", + "x-ratelimit-reset", + "retry-after", +]; + +function corsHeaders( + req: Request, + allowedOrigins: string[], +): Record { const headers: Record = { - 'access-control-allow-methods': 'GET, POST, OPTIONS', - 'access-control-allow-headers': GIT_ALLOW_HEADERS.join(', '), - 'access-control-expose-headers': GIT_EXPOSE_HEADERS.join(', '), - 'access-control-allow-credentials': 'true', - 'access-control-max-age': '600', - vary: 'Origin' - } - const origin = corsOrigin(req) - if (origin) headers['access-control-allow-origin'] = origin - return headers -} - -function hasAllowedBrowserOrigin (req: Request): boolean { - const origin = req.headers.get('origin') - return !origin || ALLOWED_ORIGINS.includes(origin) -} - -// Stateless CORS proxy for isomorphic-git smart-HTTP. The IDE is a static site -// on GitHub Pages; the browser cannot talk to github.com's git endpoints -// directly (no CORS). isomorphic-git is configured with corsProxy='/git' -// and rewrites a request to e.g. `/git//.git/info/refs?...`. -// We strip the `/git/` prefix, forward the rest verbatim to -// `https://github.com/`, stream both bodies through, and re-attach CORS -// headers. We forward only git-relevant request headers (incl. Authorization, -// which carries the token-as-basic-auth) and NEVER log them. -// -// NOTE: this function runs on Deno Deploy. Source changes take effect only -// after `deployctl deploy --project=tronide-gh-oauth main.ts`. -async function handleGitProxy (req: Request, url: URL, fetchFn: typeof fetch): Promise { - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: setSecurityHeaders(new Headers(gitCorsHeaders(req))) }) - } - if (req.method !== 'GET' && req.method !== 'POST') { - return new Response('Method not allowed', { status: 405, headers: setSecurityHeaders(new Headers(gitCorsHeaders(req))) }) - } + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "access-control-allow-headers": BFF_ALLOW_HEADERS.join(", "), + "access-control-expose-headers": BFF_EXPOSE_HEADERS.join(", "), + "access-control-max-age": "600", + vary: "Origin", + }; + const origin = corsOrigin(req, allowedOrigins); + if (origin) headers["access-control-allow-origin"] = origin; + return headers; +} - // isomorphic-git's corsProxy sends `/git///[.git]/` - // (the target host is part of the path, protocol stripped). SSRF guard: pin the - // host to github.com, allow only the exact git smart-HTTP shape, never - // `..`/`@`/`//`/backslash, and assert the constructed URL still points at - // github.com. Combined with redirect:'manual' below, the proxy cannot be - // steered to another host (which would leak the forwarded Authorization token). - const rest = url.pathname.slice('/git/'.length).replace(/^https?:\/\//i, '') - const GIT_PATH_RE = /^github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/(?:info\/refs|git-upload-pack|git-receive-pack)$/ - const GIT_QUERY_RE = /^(?:\?service=git-(?:upload|receive)-pack)?$/ - if (!rest || rest.includes('..') || rest.includes('@') || rest.includes('//') || rest.includes('\\') || - !GIT_PATH_RE.test(rest) || !GIT_QUERY_RE.test(url.search)) { - return new Response('Bad git proxy request', { status: 400, headers: setSecurityHeaders(new Headers(gitCorsHeaders(req))) }) +function responseWithCors( + req: Request, + allowedOrigins: string[], + body: BodyInit | null, + init: ResponseInit = {}, +): Response { + const headers = new Headers({ + ...corsHeaders(req, allowedOrigins), + "cache-control": "no-store", + }); + if (init.headers) { + new Headers(init.headers).forEach((value, name) => + headers.set(name, value) + ); } - let target: URL + return new Response(body, { ...init, headers: setSecurityHeaders(headers) }); +} + +function jsonWithCors( + req: Request, + allowedOrigins: string[], + payload: Record, + status = 200, + headers: HeadersInit = {}, +): Response { + return responseWithCors(req, allowedOrigins, JSON.stringify(payload), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + ...Object.fromEntries(new Headers(headers)), + }, + }); +} + +function validSessionHandle(value: string): boolean { + return /^[A-Za-z0-9_-]{43}$/.test(value); +} + +function validState(value: string): boolean { + return /^[A-Za-z0-9_-]{43}$/.test(value); +} + +function validChannel(value: string): boolean { + return /^[A-Za-z0-9_-]{16,128}$/.test(value); +} + +function isAllowedOrigin(origin: string, allowedOrigins: string[]): boolean { + return !!origin && allowedOrigins.includes(origin); +} + +function basicAuth(username: string, password: string): string { + return `Basic ${bytesToBase64(encoder.encode(`${username}:${password}`))}`; +} + +async function revokeGithubToken( + fetchFn: typeof fetch, + clientId: string, + clientSecret: string, + token: string, +): Promise { + if (!clientId || !clientSecret || !token) return; try { - target = new URL('https://' + rest + url.search) - } catch (_e) { - return new Response('Bad git proxy request', { status: 400, headers: setSecurityHeaders(new Headers(gitCorsHeaders(req))) }) - } - if (target.protocol !== 'https:' || target.hostname !== 'github.com') { - return new Response('Bad git proxy request', { status: 400, headers: setSecurityHeaders(new Headers(gitCorsHeaders(req))) }) + await fetchFn( + `https://api.github.com/applications/${ + encodeURIComponent(clientId) + }/token`, + { + method: "DELETE", + headers: { + Authorization: basicAuth(clientId, clientSecret), + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "User-Agent": "tronide-github-bff", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ access_token: token }), + redirect: "manual", + }, + ); + } catch (_error) { + // Session deletion must succeed even if GitHub is temporarily unavailable. } +} - // Forward only the git-relevant request headers. - const fwdHeaders = new Headers() - for (const name of GIT_ALLOW_HEADERS) { - const v = req.headers.get(name) - if (v !== null) fwdHeaders.set(name, v) +function allowedApiRequest( + path: string, + method: string, + searchParams: URLSearchParams, +): boolean { + let decoded: string; + try { + decoded = decodeURIComponent(path); + } catch (_error) { + return false; } + if ( + !decoded.startsWith("/") || decoded.includes("\\") || + decoded.includes("\0") || decoded.includes("//") || decoded.includes("@") + ) return false; + if ( + decoded.split("/").some((segment) => segment === "." || segment === "..") + ) return false; - let upstream: Response - try { - upstream = await fetchFn(target.toString(), { - method: req.method, - headers: fwdHeaders, - body: req.method === 'POST' ? req.body : undefined, - // Do NOT follow redirects: a redirect off github.com would otherwise make - // the proxy re-send the Authorization token to an attacker-controlled host. - redirect: 'manual' - }) - } catch (_e) { - return new Response('Upstream git request failed', { status: 502, headers: setSecurityHeaders(new Headers(gitCorsHeaders(req))) }) + const noQuery = searchParams.size === 0; + if (decoded === "/user") return method === "GET" && noQuery; + if (decoded === "/gists") return method === "POST" && noQuery; + if (/^\/gists\/[A-Za-z0-9]+$/.test(decoded)) { + return (method === "GET" || method === "PATCH") && noQuery; } + if ( + /^\/repos\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/contents(?:\/.*)?$/.test( + decoded, + ) + ) { + if (method === "PUT") return noQuery; + if (method !== "GET") return false; + return Array.from(searchParams.keys()).every((key) => key === "ref"); + } + return false; +} - // Re-attach CORS headers, preserving git-relevant upstream response headers. - const respHeaders = new Headers(gitCorsHeaders(req)) - for (const name of [...GIT_EXPOSE_HEADERS]) { - const v = upstream.headers.get(name) - if (v !== null) respHeaders.set(name, v) +function gitTarget(url: URL): URL | null { + const rest = url.pathname.slice("/git/".length).replace(/^https?:\/\//i, ""); + const pathPattern = + /^github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/(?:info\/refs|git-upload-pack|git-receive-pack)$/; + const queryPattern = /^(?:\?service=git-(?:upload|receive)-pack)?$/; + if ( + !rest || rest.includes("..") || rest.includes("@") || rest.includes("//") || + rest.includes("\\") || + !pathPattern.test(rest) || !queryPattern.test(url.search) + ) return null; + try { + const target = new URL(`https://${rest}${url.search}`); + return target.protocol === "https:" && target.hostname === "github.com" + ? target + : null; + } catch (_error) { + return null; } - return new Response(upstream.body, { status: upstream.status, headers: setSecurityHeaders(respHeaders) }) } -export function createRequestHandler (options: { +export function createRequestHandler(options: { fetchFn?: typeof fetch; rateCheck?: RateCheck; clientId?: string; clientSecret?: string; redirectUri?: string; + allowedOrigins?: string[]; + githubScope?: string; + authStore?: AuthStore | Promise; + tokenCipher?: TokenCipher | Promise; + sessionEncryptionKey?: string; + sessionTtlMs?: number; + oauthAttemptTtlMs?: number; + now?: () => number; + randomTokenFn?: (length?: number) => string; } = {}) { - const fetchFn = options.fetchFn || fetch - const rateCheck = options.rateCheck || defaultRateCheck - const clientId = options.clientId === undefined ? CLIENT_ID : options.clientId - const clientSecret = options.clientSecret === undefined ? CLIENT_SECRET : options.clientSecret - const redirectUri = options.redirectUri || REDIRECT_URI + const fetchFn = options.fetchFn || fetch; + const rateCheck = options.rateCheck || defaultRateCheck; + const clientId = options.clientId === undefined + ? CLIENT_ID + : options.clientId; + const clientSecret = options.clientSecret === undefined + ? CLIENT_SECRET + : options.clientSecret; + const redirectUri = options.redirectUri || REDIRECT_URI; + const allowedOrigins = options.allowedOrigins || ALLOWED_ORIGINS; + const githubScope = options.githubScope || GITHUB_SCOPE; + const sessionTtlMs = options.sessionTtlMs || SESSION_TTL_MS; + const oauthAttemptTtlMs = options.oauthAttemptTtlMs || OAUTH_ATTEMPT_TTL_MS; + const now = options.now || Date.now; + const makeRandomToken = options.randomTokenFn || randomToken; + let storePromise: Promise | null = options.authStore + ? Promise.resolve(options.authStore) + : null; + let cipherPromise: Promise | null = options.tokenCipher + ? Promise.resolve(options.tokenCipher) + : null; - return async (req: Request, info?: unknown): Promise => { - const url = new URL(req.url) + const getStore = (): Promise => { + if (!storePromise) { + storePromise = (async () => { + if (typeof Deno.openKv !== "function") { + throw new Error("Deno KV is required for OAuth sessions"); + } + return createKvAuthStore(await Deno.openKv(), { now }); + })(); + } + return storePromise; + }; + + const getCipher = (): Promise => { + if (!cipherPromise) { + cipherPromise = createTokenCipher( + options.sessionEncryptionKey ?? SESSION_ENCRYPTION_KEY, + ); + } + return cipherPromise; + }; - if (url.pathname === '/git' || url.pathname.startsWith('/git/')) { - if (!hasAllowedBrowserOrigin(req)) { - return new Response('Origin not allowed', { + const authenticate = async (req: Request): Promise< + | { ok: true; handle: string; session: StoredSession; token: string } + | { ok: false; response: Response } + > => { + const origin = req.headers.get("origin") || ""; + if (!isAllowedOrigin(origin, allowedOrigins)) { + return { + ok: false, + response: responseWithCors(req, allowedOrigins, "Origin not allowed", { status: 403, - headers: setSecurityHeaders(new Headers({ ...gitCorsHeaders(req), 'cache-control': 'no-store' })) - }) + }), + }; + } + if (req.headers.has("authorization")) { + return { + ok: false, + response: responseWithCors( + req, + allowedOrigins, + "Raw GitHub credentials are not accepted", + { status: 400 }, + ), + }; + } + const handle = String(req.headers.get(SESSION_HEADER) || "").trim(); + if (!validSessionHandle(handle)) { + return { + ok: false, + response: jsonWithCors(req, allowedOrigins, { + error: "session_required", + }, 401), + }; + } + try { + const store = await getStore(); + const session = await store.getSession(handle); + if (!session || session.origin !== origin) { + return { + ok: false, + response: jsonWithCors(req, allowedOrigins, { + error: "invalid_session", + }, 401), + }; + } + const token = await (await getCipher()).decrypt(session.encryptedToken); + if (!token) throw new Error("Empty token"); + return { ok: true, handle, session, token }; + } catch (_error) { + return { + ok: false, + response: jsonWithCors(req, allowedOrigins, { + error: "session_unavailable", + }, 503), + }; + } + }; + + return async (req: Request, info?: unknown): Promise => { + const url = new URL(req.url); + + if (url.pathname === "/" || url.pathname === "/health") { + return new Response("tronide-gh-oauth: ok; mode=bff-v1", { + headers: setSecurityHeaders( + new Headers({ + "cache-control": "no-store", + "x-tronide-auth-mode": "bff-v1", + }), + ), + }); + } + + if (url.pathname === "/capabilities") { + if (req.method === "OPTIONS") { + return responseWithCors(req, allowedOrigins, null, { status: 204 }); + } + if (req.method !== "GET") { + return responseWithCors(req, allowedOrigins, "Method not allowed", { + status: 405, + }); } - // Preflight does not reach GitHub and must remain cheap/reliable. - if (req.method !== 'OPTIONS') { - const authenticated = !!req.headers.get('authorization') - const limit = authenticated ? GIT_AUTH_RATE_LIMIT : GIT_PUBLIC_RATE_LIMIT - const rate = await rateCheck(req, authenticated ? 'git-auth' : 'git-public', limit, RATE_WINDOW_MS, info) - if (!rate.allowed) return rateLimitedResponse(rate, gitCorsHeaders(req)) + const requestOrigin = req.headers.get("origin"); + if (requestOrigin && !corsOrigin(req, allowedOrigins)) { + return responseWithCors(req, allowedOrigins, "Origin not allowed", { + status: 403, + }); } - return await handleGitProxy(req, url, fetchFn) + return jsonWithCors(req, allowedOrigins, { + authMode: "bff-v1", + githubTokenInBrowser: false, + }); } - if (url.pathname === '/' || url.pathname === '/health') { - return new Response('tronide-gh-oauth: ok', { - headers: setSecurityHeaders(new Headers({ 'cache-control': 'no-store' })) - }) + if (url.pathname === "/oauth/start") { + if (req.method !== "GET") { + return new Response("Method not allowed", { + status: 405, + headers: setSecurityHeaders(new Headers()), + }); + } + const origin = url.searchParams.get("origin") || ""; + const channel = url.searchParams.get("channel") || ""; + if (!isAllowedOrigin(origin, allowedOrigins)) { + return new Response("Origin not allowed", { + status: 403, + headers: setSecurityHeaders( + new Headers({ "cache-control": "no-store" }), + ), + }); + } + if (!validChannel(channel)) { + return new Response("Invalid OAuth channel", { + status: 400, + headers: setSecurityHeaders( + new Headers({ "cache-control": "no-store" }), + ), + }); + } + if (!clientId || !redirectUri) { + return new Response("OAuth server is not configured", { + status: 503, + headers: setSecurityHeaders( + new Headers({ "cache-control": "no-store" }), + ), + }); + } + + const rate = await rateCheck( + req, + "oauth-start", + OAUTH_RATE_LIMIT, + RATE_WINDOW_MS, + info, + ); + if (!rate.allowed) return rateLimitedResponse(rate); + + try { + const state = makeRandomToken(32); + const verifier = makeRandomToken(32); + if (!validState(state) || !validState(verifier)) { + throw new Error("Invalid secure random source"); + } + const challenge = await sha256Base64Url(verifier); + await (await getStore()).saveAttempt(state, { + origin, + channel, + verifier, + expiresAt: now() + oauthAttemptTtlMs, + }); + const authorize = new URL("https://github.com/login/oauth/authorize"); + authorize.search = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope: githubScope, + state, + code_challenge: challenge, + code_challenge_method: "S256", + allow_signup: "false", + prompt: "select_account", + }).toString(); + return new Response(null, { + status: 302, + headers: setSecurityHeaders( + new Headers({ + location: authorize.toString(), + "cache-control": "no-store", + ...rateHeaders(rate), + }), + ), + }); + } catch (error) { + console.error("[oauth-start] unable to persist OAuth attempt", error); + return new Response("OAuth session storage is unavailable", { + status: 503, + headers: setSecurityHeaders( + new Headers({ "cache-control": "no-store" }), + ), + }); + } } - if (url.pathname !== '/callback') { - return new Response('Not found', { status: 404, headers: setSecurityHeaders(new Headers()) }) + + if (url.pathname === "/callback") { + if (req.method !== "GET") { + return standaloneErrorPage("method_not_allowed", 405); + } + const state = url.searchParams.get("state") || ""; + if (!validState(state)) return standaloneErrorPage("invalid_state", 400); + + const rate = await rateCheck( + req, + "oauth-callback", + OAUTH_RATE_LIMIT, + RATE_WINDOW_MS, + info, + ); + if (!rate.allowed) return standaloneErrorPage("rate_limited", 429); + + let attempt: OAuthAttempt | null; + try { + attempt = await (await getStore()).consumeAttempt(state); + } catch (error) { + console.error( + "[oauth-callback] unable to consume OAuth attempt", + error, + ); + return standaloneErrorPage("session_unavailable", 503); + } + if (!attempt) { + return standaloneErrorPage("invalid_or_replayed_state", 400); + } + + const fail = (error: string, status: number) => + resultPage( + attempt!.origin, + attempt!.channel, + { error }, + status, + rateHeaders(rate), + ); + const oauthError = url.searchParams.get("error"); + if (oauthError) return fail("authorization_denied", 400); + const code = url.searchParams.get("code") || ""; + if (!/^[A-Za-z0-9_-]{10,256}$/.test(code)) { + return fail(code ? "invalid_code" : "missing_code", 400); + } + if (!clientId || !clientSecret || !redirectUri) { + return fail("server_misconfigured", 503); + } + + let token = ""; + try { + const exchange = await fetchFn( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "tronide-github-bff", + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + code_verifier: attempt.verifier, + }), + redirect: "manual", + }, + ); + if (!exchange.ok) return fail("exchange_failed", 502); + const data = await exchange.json(); + token = typeof data.access_token === "string" ? data.access_token : ""; + if (!token) return fail("exchange_failed", 400); + } catch (_error) { + return fail("exchange_request_failed", 502); + } + + let login = ""; + let userId = 0; + try { + const userResponse = await fetchFn("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "tronide-github-bff", + "X-GitHub-Api-Version": "2022-11-28", + }, + redirect: "manual", + }); + if (!userResponse.ok) { + await revokeGithubToken(fetchFn, clientId, clientSecret, token); + return fail("identity_verification_failed", 502); + } + const user = await userResponse.json(); + login = typeof user.login === "string" ? user.login : ""; + userId = Number.isSafeInteger(user.id) && user.id > 0 ? user.id : 0; + if (!login || !userId) { + await revokeGithubToken(fetchFn, clientId, clientSecret, token); + return fail("identity_verification_failed", 502); + } + } catch (_error) { + await revokeGithubToken(fetchFn, clientId, clientSecret, token); + return fail("identity_verification_failed", 502); + } + + try { + const handle = makeRandomToken(32); + if (!validSessionHandle(handle)) { + throw new Error("Invalid secure random source"); + } + const createdAt = now(); + const expiresAt = createdAt + sessionTtlMs; + const encryptedToken = await (await getCipher()).encrypt(token); + await (await getStore()).saveSession(handle, { + origin: attempt.origin, + encryptedToken, + login, + userId, + createdAt, + expiresAt, + }); + return resultPage( + attempt.origin, + attempt.channel, + { + session: handle, + login, + userId, + expiresAt, + }, + 200, + rateHeaders(rate), + ); + } catch (error) { + console.error("[oauth-callback] unable to create BFF session", error); + await revokeGithubToken(fetchFn, clientId, clientSecret, token); + return fail("session_unavailable", 503); + } } - const state = safeState(url.searchParams.get('state')) - // The frontend generates exactly 16 random bytes as 32 hexadecimal chars. - // Reject junk before it can consume a GitHub token-exchange request. - if (!/^[A-Fa-f0-9]{32}$/.test(state)) return resultPage({ state: '', error: 'invalid_state' }, 400) - - const oauthError = url.searchParams.get('error') - if (oauthError) return resultPage({ state, error: oauthError }, 400) - - const code = url.searchParams.get('code') - if (!code) return resultPage({ state, error: 'missing_code' }, 400) - if (!/^[A-Za-z0-9_-]{10,128}$/.test(code)) return resultPage({ state, error: 'invalid_code' }, 400) - if (!clientId || !clientSecret) return resultPage({ state, error: 'server_misconfigured' }, 503) - - const rate = await rateCheck(req, 'oauth-callback', OAUTH_RATE_LIMIT, RATE_WINDOW_MS, info) - if (!rate.allowed) { - return resultPage( - { state, error: 'rate_limited' }, - 429, - { ...rateHeaders(rate), 'retry-after': String(Math.max(1, Math.ceil((rate.resetAt - Date.now()) / 1000))) } - ) + if (url.pathname === "/session") { + if (req.method === "OPTIONS") { + return responseWithCors(req, allowedOrigins, null, { status: 204 }); + } + if (req.method !== "GET" && req.method !== "DELETE") { + return responseWithCors(req, allowedOrigins, "Method not allowed", { + status: 405, + }); + } + const authenticated = await authenticate(req); + if (!authenticated.ok) return authenticated.response; + if (req.method === "DELETE") { + try { + await (await getStore()).deleteSession(authenticated.handle); + await revokeGithubToken( + fetchFn, + clientId, + clientSecret, + authenticated.token, + ); + } catch (_error) { + return jsonWithCors(req, allowedOrigins, { + error: "session_unavailable", + }, 503); + } + return responseWithCors(req, allowedOrigins, null, { status: 204 }); + } + return jsonWithCors(req, allowedOrigins, { + connected: true, + login: authenticated.session.login, + userId: authenticated.session.userId, + expiresAt: authenticated.session.expiresAt, + }); } - // Exchange the code for an access token (secret stays here). - let token = '' - try { - const res = await fetchFn('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }) - }) - const data = await res.json() - token = data.access_token ?? '' - if (!token) return resultPage({ state, error: data.error_description || data.error || 'exchange_failed' }, 400, rateHeaders(rate)) - } catch (_e) { - return resultPage({ state, error: 'exchange_request_failed' }, 502, rateHeaders(rate)) + if (url.pathname === "/api" || url.pathname.startsWith("/api/")) { + if (req.method === "OPTIONS") { + return responseWithCors(req, allowedOrigins, null, { status: 204 }); + } + const apiPath = url.pathname.slice("/api".length); + if (!allowedApiRequest(apiPath, req.method, url.searchParams)) { + return responseWithCors( + req, + allowedOrigins, + "GitHub API operation not allowed", + { status: 403 }, + ); + } + const rate = await rateCheck( + req, + "github-api", + API_RATE_LIMIT, + RATE_WINDOW_MS, + info, + ); + if (!rate.allowed) { + return rateLimitedResponse(rate, corsHeaders(req, allowedOrigins)); + } + const authenticated = await authenticate(req); + if (!authenticated.ok) return authenticated.response; + + let requestBody: Uint8Array | undefined; + if (req.method !== "GET" && req.method !== "HEAD") { + const declaredLength = Number(req.headers.get("content-length") || 0); + if (declaredLength > MAX_API_BODY_BYTES) { + return responseWithCors( + req, + allowedOrigins, + "Request body too large", + { status: 413 }, + ); + } + const body = new Uint8Array(await req.arrayBuffer()); + if (body.byteLength > MAX_API_BODY_BYTES) { + return responseWithCors( + req, + allowedOrigins, + "Request body too large", + { status: 413 }, + ); + } + requestBody = body; + } + + const upstreamHeaders = new Headers({ + Authorization: `Bearer ${authenticated.token}`, + Accept: req.headers.get("accept") || "application/vnd.github+json", + "User-Agent": "tronide-github-bff", + "X-GitHub-Api-Version": "2022-11-28", + }); + const contentType = req.headers.get("content-type"); + if (contentType) upstreamHeaders.set("content-type", contentType); + const etag = req.headers.get("if-none-match"); + if (etag) upstreamHeaders.set("if-none-match", etag); + + let upstream: Response; + try { + upstream = await fetchFn( + `https://api.github.com${apiPath}${url.search}`, + { + method: req.method, + headers: upstreamHeaders, + body: requestBody, + redirect: "manual", + }, + ); + } catch (_error) { + return responseWithCors( + req, + allowedOrigins, + "Upstream GitHub API request failed", + { status: 502 }, + ); + } + if (upstream.status >= 300 && upstream.status < 400) { + return responseWithCors( + req, + allowedOrigins, + "Upstream redirect rejected", + { status: 502 }, + ); + } + if (upstream.status === 401) { + await (await getStore()).deleteSession(authenticated.handle); + } + + const responseHeaders = new Headers({ + ...corsHeaders(req, allowedOrigins), + ...rateHeaders(rate), + "cache-control": "no-store", + }); + for (const name of BFF_EXPOSE_HEADERS) { + const value = upstream.headers.get(name); + if (value !== null) responseHeaders.set(name, value); + } + return new Response(upstream.body, { + status: upstream.status, + headers: setSecurityHeaders(responseHeaders), + }); } - // Resolve the login server-side (no browser CORS to api.github.com needed). - let login = '' - try { - const ures = await fetchFn('https://api.github.com/user', { - headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'tronide-gh-oauth' } - }) - if (ures.ok) login = (await ures.json()).login ?? '' - } catch (_e) { /* login is best-effort; the token still works */ } + if (url.pathname === "/git" || url.pathname.startsWith("/git/")) { + if (req.method === "OPTIONS") { + return responseWithCors(req, allowedOrigins, null, { status: 204 }); + } + const origin = req.headers.get("origin") || ""; + if (!isAllowedOrigin(origin, allowedOrigins)) { + return responseWithCors(req, allowedOrigins, "Origin not allowed", { + status: 403, + }); + } + if (req.method !== "GET" && req.method !== "POST") { + return responseWithCors(req, allowedOrigins, "Method not allowed", { + status: 405, + }); + } + if (req.headers.has("authorization")) { + return responseWithCors( + req, + allowedOrigins, + "Raw GitHub credentials are not accepted", + { status: 400 }, + ); + } + const target = gitTarget(url); + if (!target) { + return responseWithCors(req, allowedOrigins, "Bad git proxy request", { + status: 400, + }); + } - return resultPage({ state, token, login }, 200, rateHeaders(rate)) - } + const declaredGitLength = Number(req.headers.get("content-length") || 0); + if ( + req.method === "POST" && + (!Number.isFinite(declaredGitLength) || + declaredGitLength > MAX_GIT_BODY_BYTES) + ) { + return responseWithCors(req, allowedOrigins, "Request body too large", { + status: 413, + }); + } + + const hasSession = !!req.headers.get(SESSION_HEADER); + let authenticated: { handle: string; token: string } | null = null; + if (hasSession) { + const result = await authenticate(req); + if (!result.ok) return result.response; + authenticated = { handle: result.handle, token: result.token }; + } + const rate = await rateCheck( + req, + authenticated ? "git-auth" : "git-public", + authenticated ? GIT_AUTH_RATE_LIMIT : GIT_PUBLIC_RATE_LIMIT, + RATE_WINDOW_MS, + info, + ); + if (!rate.allowed) { + return rateLimitedResponse(rate, corsHeaders(req, allowedOrigins)); + } + + const upstreamHeaders = new Headers(); + for ( + const name of [ + "accept", + "content-type", + "git-protocol", + "pragma", + "cache-control", + ] + ) { + const value = req.headers.get(name); + if (value !== null) upstreamHeaders.set(name, value); + } + upstreamHeaders.set("user-agent", "tronide-github-bff"); + if (authenticated) { + upstreamHeaders.set( + "authorization", + // OAuth App access tokens authenticate Git smart-HTTP as the Basic + // username. (GitHub App installation tokens use the password slot; + // this service currently owns an OAuth App token.) + basicAuth(authenticated.token, "x-oauth-basic"), + ); + } + + let upstream: Response; + try { + upstream = await fetchFn(target.toString(), { + method: req.method, + headers: upstreamHeaders, + body: req.method === "POST" ? req.body : undefined, + redirect: "manual", + }); + } catch (_error) { + return responseWithCors( + req, + allowedOrigins, + "Upstream git request failed", + { status: 502 }, + ); + } + if (upstream.status >= 300 && upstream.status < 400) { + return responseWithCors( + req, + allowedOrigins, + "Upstream redirect rejected", + { status: 502 }, + ); + } + if (upstream.status === 401 && authenticated) { + await (await getStore()).deleteSession(authenticated.handle); + } + + const responseHeaders = new Headers({ + ...corsHeaders(req, allowedOrigins), + ...rateHeaders(rate), + "cache-control": "no-store", + }); + for ( + const name of [ + "content-type", + "cache-control", + "expires", + "pragma", + "www-authenticate", + ] + ) { + const value = upstream.headers.get(name); + if (value !== null) responseHeaders.set(name, value); + } + // Never let upstream cache policy make a private, session-authenticated + // Git response reusable by a browser or intermediary. + responseHeaders.set("cache-control", "no-store"); + return new Response(upstream.body, { + status: upstream.status, + headers: setSecurityHeaders(responseHeaders), + }); + } + + return new Response("Not found", { + status: 404, + headers: setSecurityHeaders(new Headers()), + }); + }; } -if (import.meta.main) Deno.serve(createRequestHandler()) +if (import.meta.main) Deno.serve(createRequestHandler()); diff --git a/services/github-oauth/main_test.ts b/services/github-oauth/main_test.ts index 769b9ff0d..e06592950 100644 --- a/services/github-oauth/main_test.ts +++ b/services/github-oauth/main_test.ts @@ -1,110 +1,529 @@ -import { createRateLimiter, createRequestHandler } from './main.ts' +import { + type AuthStore, + createMemoryAuthStore, + createRateLimiter, + createRequestHandler, + createTokenCipher, + type TokenCipher, +} from "./main.ts"; -function assert (condition: unknown, message: string): asserts condition { - if (!condition) throw new Error(message) +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); } +function assertEquals(actual: unknown, expected: unknown, message: string) { + if (actual !== expected) { + throw new Error( + `${message}: expected ${String(expected)}, received ${String(actual)}`, + ); + } +} + +const ORIGIN = "https://tronide.io"; +const CHANNEL = "channel_0123456789abcdef"; +const STATE = "s".repeat(43); +const VERIFIER = "v".repeat(43); +const SESSION = "h".repeat(43); + const allowRate = (_req: Request, _bucket: string, limit: number) => ({ allowed: true, limit, remaining: limit - 1, - resetAt: Date.now() + 60_000 -}) - -Deno.test('rate limiter rejects requests after the configured per-client allowance', async () => { - let now = 1_000 - const check = createRateLimiter({ now: () => now, kvFactory: () => null }) - const req = new Request('https://proxy.example/callback', { headers: { 'x-forwarded-for': '203.0.113.4' } }) - assert((await check(req, 'test', 2, 60_000)).allowed, 'first request should pass') - assert((await check(req, 'test', 2, 60_000)).allowed, 'second request should pass') - const denied = await check(req, 'test', 2, 60_000) - assert(!denied.allowed && denied.remaining === 0, 'third request should be denied') - now += 60_001 - assert((await check(req, 'test', 2, 60_000)).allowed, 'request should pass after reset') -}) - -Deno.test('rate limiter falls back when KV initialization throws synchronously', async () => { + resetAt: Date.now() + 60_000, +}); + +function testKey(): string { + return btoa(String.fromCharCode(...new Uint8Array(32).fill(7))); +} + +async function createTestDependencies(): Promise<{ + store: AuthStore; + cipher: TokenCipher; +}> { + return { + store: createMemoryAuthStore(), + cipher: await createTokenCipher(testKey()), + }; +} + +async function beginOAuth( + handler: ReturnType, +): Promise<{ response: Response; authorize: URL; state: string }> { + const response = await handler( + new Request( + `https://proxy.example/oauth/start?origin=${ + encodeURIComponent(ORIGIN) + }&channel=${CHANNEL}`, + ), + ); + const authorize = new URL(response.headers.get("location") || ""); + return { + response, + authorize, + state: authorize.searchParams.get("state") || "", + }; +} + +Deno.test("rate limiter rejects requests after the configured per-client allowance", async () => { + let now = 1_000; + const check = createRateLimiter({ now: () => now, kvFactory: () => null }); + const req = new Request("https://proxy.example/callback", { + headers: { "x-forwarded-for": "203.0.113.4" }, + }); + assert( + (await check(req, "test", 2, 60_000)).allowed, + "first request should pass", + ); + assert( + (await check(req, "test", 2, 60_000)).allowed, + "second request should pass", + ); + const denied = await check(req, "test", 2, 60_000); + assert( + !denied.allowed && denied.remaining === 0, + "third request should be denied", + ); + now += 60_001; + assert( + (await check(req, "test", 2, 60_000)).allowed, + "request should pass after reset", + ); +}); + +Deno.test("rate limiter falls back when KV initialization throws synchronously", async () => { const check = createRateLimiter({ - kvFactory: () => { throw new Error('KV is not attached') } - }) - const req = new Request('https://proxy.example/callback', { headers: { 'x-forwarded-for': '203.0.113.5' } }) + kvFactory: () => { + throw new Error("KV is not attached"); + }, + }); + const req = new Request("https://proxy.example/callback", { + headers: { "x-forwarded-for": "203.0.113.5" }, + }); - const first = await check(req, 'test', 2, 60_000) - const second = await check(req, 'test', 2, 60_000) - const denied = await check(req, 'test', 2, 60_000) + const first = await check(req, "test", 2, 60_000); + const second = await check(req, "test", 2, 60_000); + const denied = await check(req, "test", 2, 60_000); - assert(first.allowed && second.allowed, 'local fallback should preserve the configured allowance') - assert(!denied.allowed && denied.remaining === 0, 'local fallback should still enforce the limit') -}) + assert( + first.allowed && second.allowed, + "local fallback should preserve the configured allowance", + ); + assert( + !denied.allowed && denied.remaining === 0, + "local fallback should still enforce the limit", + ); +}); -Deno.test('git proxy rejects a disallowed browser origin without contacting GitHub', async () => { - let fetches = 0 +Deno.test("capabilities advertises the tokenless BFF only to allowed browser origins", async () => { const handler = createRequestHandler({ + allowedOrigins: [ORIGIN], rateCheck: allowRate, - fetchFn: () => { fetches++; return Promise.resolve(new Response('unexpected')) } - }) - const response = await handler(new Request( - 'https://proxy.example/git/github.com/tronprotocol/tronbox/info/refs?service=git-upload-pack', - { headers: { origin: 'https://evil.example' } } - )) - assert(response.status === 403, 'disallowed origin should receive HTTP 403') - assert(fetches === 0, 'disallowed origin must not reach the upstream fetch') -}) - -Deno.test('git proxy returns HTTP 429 before contacting GitHub when quota is exhausted', async () => { - let fetches = 0 + }); + const allowed = await handler( + new Request("https://proxy.example/capabilities", { + headers: { origin: ORIGIN }, + }), + ); + assertEquals(allowed.status, 200, "allowed origin should read capabilities"); + assertEquals( + allowed.headers.get("access-control-allow-origin"), + ORIGIN, + "capabilities response should be readable cross-origin", + ); + const payload = await allowed.json(); + assertEquals(payload.authMode, "bff-v1", "BFF mode should be explicit"); + assertEquals( + payload.githubTokenInBrowser, + false, + "capabilities must promise that GitHub tokens stay server-side", + ); + + const disallowed = await handler( + new Request("https://proxy.example/capabilities", { + headers: { origin: "https://evil.example" }, + }), + ); + assertEquals(disallowed.status, 403, "disallowed origin should be rejected"); +}); + +Deno.test("OAuth start owns state and PKCE and always selects an account", async () => { + const { store, cipher } = await createTestDependencies(); + const values = [STATE, VERIFIER]; const handler = createRequestHandler({ - rateCheck: (_req, _bucket, limit) => ({ allowed: false, limit, remaining: 0, resetAt: Date.now() + 30_000 }), - fetchFn: () => { fetches++; return Promise.resolve(new Response('unexpected')) } - }) - const response = await handler(new Request( - 'https://proxy.example/git/github.com/tronprotocol/tronbox/info/refs?service=git-upload-pack' - )) - assert(response.status === 429, 'exhausted client should receive HTTP 429') - assert(response.headers.has('retry-after'), '429 response should advertise Retry-After') - assert(fetches === 0, 'rate-limited request must not reach GitHub') -}) - -Deno.test('OAuth callback rejects malformed state before contacting GitHub', async () => { - let fetches = 0 + clientId: "client", + clientSecret: "secret", + redirectUri: "https://proxy.example/callback", + allowedOrigins: [ORIGIN], + authStore: store, + tokenCipher: cipher, + rateCheck: allowRate, + randomTokenFn: () => values.shift() || SESSION, + }); + + const { response, authorize, state } = await beginOAuth(handler); + assertEquals(response.status, 302, "OAuth start should redirect"); + assertEquals( + authorize.origin, + "https://github.com", + "authorization must be pinned to GitHub", + ); + assertEquals( + authorize.pathname, + "/login/oauth/authorize", + "authorization path should be GitHub OAuth", + ); + assertEquals( + authorize.searchParams.get("client_id"), + "client", + "server should add the client id", + ); + assertEquals( + authorize.searchParams.get("state"), + STATE, + "server should generate OAuth state", + ); + assertEquals( + authorize.searchParams.get("code_challenge_method"), + "S256", + "PKCE should use S256", + ); + assert( + (authorize.searchParams.get("code_challenge") || "").length === 43, + "PKCE challenge should be present", + ); + assertEquals( + authorize.searchParams.get("prompt"), + "select_account", + "account picker must always be requested", + ); + assertEquals(state, STATE, "test should capture server state"); +}); + +Deno.test("OAuth callback keeps the GitHub token server-side and rejects state replay", async () => { + const { store, cipher } = await createTestDependencies(); + const values = [STATE, VERIFIER, SESSION]; + const calls: Array<{ url: string; init?: RequestInit }> = []; const handler = createRequestHandler({ - clientId: 'client', - clientSecret: 'secret', + clientId: "client", + clientSecret: "secret", + redirectUri: "https://proxy.example/callback", + allowedOrigins: [ORIGIN], + authStore: store, + tokenCipher: cipher, rateCheck: allowRate, - fetchFn: () => { fetches++; return Promise.resolve(new Response('unexpected')) } - }) - const response = await handler(new Request('https://proxy.example/callback?code=VALID_CODE_123&state=AAAAAAAAAA')) - assert(response.status === 400, 'malformed state should receive HTTP 400') - assert(fetches === 0, 'malformed state must not reach GitHub') -}) - -Deno.test('OAuth callback preserves the successful exchange flow with security headers', async () => { - const calls: string[] = [] + randomTokenFn: () => values.shift() || "x".repeat(43), + fetchFn: (input, init) => { + const url = String(input); + calls.push({ url, init }); + if (url.endsWith("/login/oauth/access_token")) { + return Promise.resolve( + Response.json({ access_token: "github-secret-token" }), + ); + } + if (url.endsWith("/user")) { + return Promise.resolve(Response.json({ login: "tron-user", id: 42 })); + } + return Promise.resolve(new Response("unexpected", { status: 500 })); + }, + }); + + const { state } = await beginOAuth(handler); + const callback = await handler( + new Request( + `https://proxy.example/callback?code=VALID_CODE_123&state=${state}`, + ), + ); + const body = await callback.text(); + assertEquals(callback.status, 200, "valid callback should succeed"); + assertEquals( + calls.length, + 2, + "callback should exchange the code and verify the user", + ); + assert( + !body.includes("github-secret-token"), + "callback HTML must never contain the GitHub token", + ); + assert( + body.includes(`\"session\":\"${SESSION}\"`), + "callback should return only the BFF session", + ); + assert( + body.includes('"login":"tron-user"'), + "callback should return the verified login", + ); + assert( + body.includes(`postMessage(data, \"${ORIGIN}\")`), + "result should target only the initiating origin", + ); + + const exchangeBody = JSON.parse(String(calls[0].init?.body || "{}")); + assertEquals( + exchangeBody.code_verifier, + VERIFIER, + "token exchange should use the stored PKCE verifier", + ); + + const replay = await handler( + new Request( + `https://proxy.example/callback?code=VALID_CODE_123&state=${state}`, + ), + ); + assertEquals(replay.status, 400, "replayed state must fail"); + assertEquals( + calls.length, + 2, + "replayed state must fail before GitHub is contacted", + ); +}); + +Deno.test("session is origin-bound and can be revoked", async () => { + const { store, cipher } = await createTestDependencies(); + await store.saveSession(SESSION, { + origin: ORIGIN, + encryptedToken: await cipher.encrypt("github-secret-token"), + login: "tron-user", + userId: 42, + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const upstreamCalls: string[] = []; const handler = createRequestHandler({ - clientId: 'client', - clientSecret: 'secret', + clientId: "client", + clientSecret: "secret", + allowedOrigins: [ORIGIN, "https://other.example"], + authStore: store, + tokenCipher: cipher, rateCheck: allowRate, fetchFn: (input) => { - const url = String(input) - calls.push(url) - if (url.includes('/login/oauth/access_token')) { - return Promise.resolve(new Response(JSON.stringify({ access_token: 'github-token' }), { - status: 200, - headers: { 'content-type': 'application/json' } - })) - } - return Promise.resolve(new Response(JSON.stringify({ login: 'tron-user' }), { - status: 200, - headers: { 'content-type': 'application/json' } - })) - } - }) - const state = '0123456789abcdef0123456789abcdef' - const response = await handler(new Request(`https://proxy.example/callback?code=VALID_CODE_123&state=${state}`)) - const body = await response.text() - assert(response.status === 200, 'valid callback should still succeed') - assert(calls.length === 2, 'successful callback should exchange the code and resolve the user') - assert(body.includes('github-token') && body.includes('tron-user'), 'result page should return token and login to the opener') - assert(response.headers.get('strict-transport-security') === 'max-age=31536000', 'callback should send HSTS') - assert(response.headers.get('x-ratelimit-remaining') !== null, 'callback should expose quota state') -}) + upstreamCalls.push(String(input)); + return Promise.resolve(new Response(null, { status: 204 })); + }, + }); + + const sessionRequest = (origin: string, method = "GET") => + new Request("https://proxy.example/session", { + method, + headers: { origin, "x-tronide-session": SESSION }, + }); + + const valid = await handler(sessionRequest(ORIGIN)); + assertEquals(valid.status, 200, "issuing origin should validate the session"); + assertEquals( + (await valid.json()).login, + "tron-user", + "session should expose the verified login", + ); + + const wrongOrigin = await handler(sessionRequest("https://other.example")); + assertEquals( + wrongOrigin.status, + 401, + "a different allow-listed origin must not reuse the session", + ); + + const deleted = await handler(sessionRequest(ORIGIN, "DELETE")); + assertEquals(deleted.status, 204, "disconnect should revoke the session"); + assert( + upstreamCalls.some((url) => url.includes("/applications/client/token")), + "disconnect should best-effort revoke the GitHub OAuth token", + ); + const afterDelete = await handler(sessionRequest(ORIGIN)); + assertEquals(afterDelete.status, 401, "revoked session must not be reusable"); +}); + +Deno.test("restricted REST BFF injects the server token and rejects browser credentials", async () => { + const { store, cipher } = await createTestDependencies(); + await store.saveSession(SESSION, { + origin: ORIGIN, + encryptedToken: await cipher.encrypt("github-secret-token"), + login: "tron-user", + userId: 42, + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + let upstreamAuthorization = ""; + let upstreamUrl = ""; + const handler = createRequestHandler({ + allowedOrigins: [ORIGIN], + authStore: store, + tokenCipher: cipher, + rateCheck: allowRate, + fetchFn: (input, init) => { + upstreamUrl = String(input); + upstreamAuthorization = new Headers(init?.headers).get("authorization") || + ""; + return Promise.resolve(Response.json({ login: "tron-user" })); + }, + }); + const headers = { origin: ORIGIN, "x-tronide-session": SESSION }; + + const allowed = await handler( + new Request("https://proxy.example/api/user", { headers }), + ); + assertEquals(allowed.status, 200, "allow-listed API operation should pass"); + assertEquals( + upstreamUrl, + "https://api.github.com/user", + "API target should be pinned to GitHub", + ); + assertEquals( + upstreamAuthorization, + "Bearer github-secret-token", + "BFF should inject its decrypted token", + ); + assertEquals( + allowed.headers.get("cache-control"), + "no-store", + "authenticated GitHub API responses must never be cached", + ); + + const forbidden = await handler( + new Request("https://proxy.example/api/orgs/tronweb3/members", { headers }), + ); + assertEquals( + forbidden.status, + 403, + "arbitrary GitHub API paths must be rejected", + ); + + const rawCredential = await handler( + new Request("https://proxy.example/api/user", { + headers: { ...headers, authorization: "Bearer browser-token" }, + }), + ); + assertEquals( + rawCredential.status, + 400, + "browser GitHub credentials must be rejected", + ); +}); + +Deno.test("git proxy allows anonymous public reads and injects session auth only server-side", async () => { + const { store, cipher } = await createTestDependencies(); + await store.saveSession(SESSION, { + origin: ORIGIN, + encryptedToken: await cipher.encrypt("github-secret-token"), + login: "tron-user", + userId: 42, + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const authorizations: string[] = []; + const handler = createRequestHandler({ + allowedOrigins: [ORIGIN], + authStore: store, + tokenCipher: cipher, + rateCheck: allowRate, + fetchFn: (_input, init) => { + authorizations.push( + new Headers(init?.headers).get("authorization") || "", + ); + return Promise.resolve(new Response("git-data", { status: 200 })); + }, + }); + const url = + "https://proxy.example/git/github.com/tronprotocol/tronbox/info/refs?service=git-upload-pack"; + + const publicResponse = await handler( + new Request(url, { headers: { origin: ORIGIN } }), + ); + assertEquals( + publicResponse.status, + 200, + "anonymous public Git request should pass", + ); + assertEquals( + authorizations[0], + "", + "anonymous request must not get credentials", + ); + + const privateResponse = await handler( + new Request(url, { + headers: { origin: ORIGIN, "x-tronide-session": SESSION }, + }), + ); + assertEquals( + privateResponse.status, + 200, + "session-authenticated Git request should pass", + ); + assert( + authorizations[1].startsWith("Basic ") && + !authorizations[1].includes("github-secret-token"), + "GitHub Basic auth should be created only inside the BFF", + ); + assertEquals( + atob(authorizations[1].slice("Basic ".length)), + "github-secret-token:x-oauth-basic", + "OAuth App token must be the Git HTTPS username", + ); + assertEquals( + privateResponse.headers.get("cache-control"), + "no-store", + "authenticated Git responses must never be cached", + ); + + const rawCredential = await handler( + new Request(url, { + headers: { origin: ORIGIN, authorization: "Basic browser-token" }, + }), + ); + assertEquals( + rawCredential.status, + 400, + "raw browser Git credentials must be rejected", + ); + + const oversized = await handler( + new Request( + url.replace("info/refs?service=git-upload-pack", "git-receive-pack"), + { + method: "POST", + headers: { + origin: ORIGIN, + "content-length": String(64 * 1024 * 1024 + 1), + }, + body: "oversized", + }, + ), + ); + assertEquals(oversized.status, 413, "oversized Git uploads must be rejected"); +}); + +Deno.test("git proxy rejects disallowed origins and SSRF paths before upstream fetch", async () => { + const { store, cipher } = await createTestDependencies(); + let fetches = 0; + const handler = createRequestHandler({ + allowedOrigins: [ORIGIN], + authStore: store, + tokenCipher: cipher, + rateCheck: allowRate, + fetchFn: () => { + fetches++; + return Promise.resolve(new Response("unexpected")); + }, + }); + + const disallowed = await handler( + new Request( + "https://proxy.example/git/github.com/tronprotocol/tronbox/info/refs?service=git-upload-pack", + { headers: { origin: "https://evil.example" } }, + ), + ); + assertEquals(disallowed.status, 403, "disallowed origin should be rejected"); + + const ssrf = await handler( + new Request( + "https://proxy.example/git/github.com@evil.example/tronprotocol/tronbox/info/refs?service=git-upload-pack", + { headers: { origin: ORIGIN } }, + ), + ); + assertEquals(ssrf.status, 400, "invalid Git target should be rejected"); + assertEquals( + fetches, + 0, + "rejected Git requests must not contact an upstream", + ); +}); From 4f34f52de2c51b2a5c7cea4f5cda48038267835b Mon Sep 17 00:00:00 2001 From: Redchar1992 Date: Fri, 14 Aug 2026 12:25:27 +0800 Subject: [PATCH 2/2] fix(github): require organization-owned OAuth endpoints - Remove personal Deno and callback fallbacks from frontend and BFF configuration.\n- Fail deployments closed until the organization BFF origin is configured.\n- Align migration guidance and regression coverage with the online cutover. --- .env.example | 4 ++-- .github/workflows/deploy.yml | 16 ++++++++++++++ apps/remix-ide-pw/tests/git-remote.spec.ts | 16 ++++++++++++++ apps/remix-ide/.env.example | 2 +- apps/remix-ide/src/lib/github-bff.js | 3 ++- .../test/audit-20260721-remediation-test.js | 4 ++++ apps/remix-ide/test/gist-handler-test.js | 3 ++- apps/remix-ide/webpack.config.js | 2 +- services/github-oauth/BFF_MIGRATION.md | 22 ++++++++++--------- services/github-oauth/README.md | 7 +++--- services/github-oauth/deno.json | 6 ++--- services/github-oauth/main.ts | 8 +++---- 12 files changed, 66 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index ae0a102b6..cea8b22cc 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,8 @@ account_password= NODE_OPTIONS=--max-old-space-size=2048 # Public GitHub OAuth/BFF deployment origin (no trailing slash). -# Override when the team-owned Deno project/domain is ready. -TRONIDE_GITHUB_BFF_ORIGIN=https://tronide-gh-oauth.redchar1992.deno.net +# Use the organization-owned Deno project; never fall back to a personal deployment. +TRONIDE_GITHUB_BFF_ORIGIN=https://tronide-gh-oauth..deno.net # Public TronGrid API key inlined into the IDE bundle at build time. # Optional. When unset, the IDE falls back to anonymous TronGrid rate limits. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4ba0bfa64..ea335e329 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -57,6 +57,22 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate GitHub BFF configuration + env: + TRONIDE_GITHUB_BFF_ORIGIN: ${{ vars.TRONIDE_GITHUB_BFF_ORIGIN }} + run: | + if [ -z "$TRONIDE_GITHUB_BFF_ORIGIN" ]; then + echo "::error::Set the TRONIDE_GITHUB_BFF_ORIGIN repository variable before deploying." + exit 1 + fi + case "$TRONIDE_GITHUB_BFF_ORIGIN" in + https://*) ;; + *) + echo "::error::TRONIDE_GITHUB_BFF_ORIGIN must be an HTTPS origin." + exit 1 + ;; + esac + # build.sh runs the production nx build (baseHref "./", path-agnostic) and # normalises index.html. Output: build/apps/remix-ide. - name: Build diff --git a/apps/remix-ide-pw/tests/git-remote.spec.ts b/apps/remix-ide-pw/tests/git-remote.spec.ts index e9d7f9f1e..e656463ae 100644 --- a/apps/remix-ide-pw/tests/git-remote.spec.ts +++ b/apps/remix-ide-pw/tests/git-remote.spec.ts @@ -90,8 +90,21 @@ test.describe('Git panel (remote)', () => { } let proxiedPushes = 0 + const proxiedHeaders: Array> = [] await page.route('**/git/**', (route) => { + const request = route.request() + if (request.method() === 'OPTIONS') { + return route.fulfill({ + status: 204, + headers: { + 'access-control-allow-origin': request.headers().origin || new URL(page.url()).origin, + 'access-control-allow-methods': 'GET, POST, OPTIONS', + 'access-control-allow-headers': request.headers()['access-control-request-headers'] || 'x-tronide-session' + } + }) + } proxiedPushes++ + proxiedHeaders.push(request.headers()) return route.abort() }) await page.locator('[data-id="gitAddRemoteUrl"]').fill('https://github.com/octocat/Hello-World.git') @@ -100,6 +113,7 @@ test.describe('Git panel (remote)', () => { // Add remote now performs an all-ref fetch. This test intentionally aborts // that request to remain an offline @gate test; count only later pushes. proxiedPushes = 0 + proxiedHeaders.length = 0 // Approval is scoped to the branch that was visible in the modal. Switch // branches programmatically while it is open; confirming the stale modal @@ -133,6 +147,8 @@ test.describe('Git panel (remote)', () => { await page.locator('[data-id="gitForcePush"]').click() await page.locator('#modal-footer-ok').click() await expect.poll(() => proxiedPushes, { timeout: 15_000 }).toBeGreaterThan(0) + expect(proxiedHeaders.every((headers) => headers['x-tronide-session'] === 'test_bff_session_handle_012345678901234567890')).toBe(true) + expect(proxiedHeaders.every((headers) => !headers.authorization)).toBe(true) await expect(page.locator('[data-id="gitStatus"]')).toContainText(/push failed/i, { timeout: 15_000 }) // Ordinary Push remains direct: no destructive-action modal is introduced. diff --git a/apps/remix-ide/.env.example b/apps/remix-ide/.env.example index 31a12a9f1..9d2358371 100644 --- a/apps/remix-ide/.env.example +++ b/apps/remix-ide/.env.example @@ -9,4 +9,4 @@ account_passphrase= account_password= # Public GitHub OAuth/BFF deployment origin (no trailing slash). -TRONIDE_GITHUB_BFF_ORIGIN=https://tronide-gh-oauth.redchar1992.deno.net +TRONIDE_GITHUB_BFF_ORIGIN=https://tronide-gh-oauth..deno.net diff --git a/apps/remix-ide/src/lib/github-bff.js b/apps/remix-ide/src/lib/github-bff.js index f12351906..d7333d85b 100644 --- a/apps/remix-ide/src/lib/github-bff.js +++ b/apps/remix-ide/src/lib/github-bff.js @@ -9,12 +9,13 @@ import * as githubAuth from './github-auth.js' export const GITHUB_BFF = { - origin: String(process.env.TRONIDE_GITHUB_BFF_ORIGIN || 'https://tronide-gh-oauth.redchar1992.deno.net').replace(/\/$/, ''), + origin: String(process.env.TRONIDE_GITHUB_BFF_ORIGIN || '').replace(/\/$/, ''), sessionHeader: 'X-TronIDE-Session' } function bffUrl (path) { const normalized = String(path || '') + if (!GITHUB_BFF.origin) throw new Error('GitHub BFF is not configured.') if (!normalized.startsWith('/')) throw new Error('GitHub BFF path must be absolute.') return GITHUB_BFF.origin + normalized } diff --git a/apps/remix-ide/test/audit-20260721-remediation-test.js b/apps/remix-ide/test/audit-20260721-remediation-test.js index 3de47088a..57991176f 100644 --- a/apps/remix-ide/test/audit-20260721-remediation-test.js +++ b/apps/remix-ide/test/audit-20260721-remediation-test.js @@ -42,6 +42,10 @@ test('URL imports, wallet events, OAuth messages, and AI staging keep their secu t.ok(bff.indexOf('code_challenge_method: "S256"') !== -1, 'server-owned OAuth request must use PKCE') t.equal(oauth.indexOf('access_token'), -1, 'frontend OAuth code must never receive a GitHub access token') t.ok(oauth.indexOf('assertBffReady()') !== -1 && githubBff.indexOf("authMode !== 'bff-v1'") !== -1, 'frontend fails closed instead of falling back to the legacy token-returning proxy') + t.ok(githubBff.indexOf('if (!GITHUB_BFF.origin)') !== -1, 'frontend fails closed when the organization BFF origin is missing') + t.ok(githubBff.indexOf("process.env.TRONIDE_GITHUB_BFF_ORIGIN || ''") !== -1, 'frontend has no hard-coded Deno deployment fallback') + t.ok(bff.indexOf('Deno.env.get("REDIRECT_URI") ?? ""') !== -1, 'BFF has no hard-coded callback fallback') + t.ok(bff.indexOf('Deno.env.get("ALLOWED_ORIGINS") ?? ""') !== -1, 'BFF has no implicit production-origin allowlist') t.ok(chat.indexOf("title: 'AI wants to stage all workspace changes'") !== -1, 'git_stage_all asks for confirmation') t.ok(chat.indexOf("title: 'AI wants to stage workspace files'") !== -1, 'git_stage asks for confirmation') t.end() diff --git a/apps/remix-ide/test/gist-handler-test.js b/apps/remix-ide/test/gist-handler-test.js index 725ece9d8..09082f39d 100644 --- a/apps/remix-ide/test/gist-handler-test.js +++ b/apps/remix-ide/test/gist-handler-test.js @@ -20,6 +20,7 @@ 'use strict' var test = require('tape') +process.env.TRONIDE_GITHUB_BFF_ORIGIN = 'https://tronide-github-bff.test' var GistHandler = require('../src/lib/gist-handler') var githubAuth = require('../src/lib/github-auth') @@ -211,7 +212,7 @@ test('GistHandler.loadFromGist does not send Authorization on raw_url fetches (C var readHeader = function (name) { return headers && typeof headers.get === 'function' ? headers.get(name) : headers[name] } - if (u.indexOf('tronide-gh-oauth.redchar1992.deno.net/api/gists/') !== -1) { + if (u.indexOf('tronide-github-bff.test/api/gists/') !== -1) { apiSession = readHeader('X-TronIDE-Session') var payload = JSON.stringify({ id: GID, diff --git a/apps/remix-ide/webpack.config.js b/apps/remix-ide/webpack.config.js index e4695fdcd..713a47187 100644 --- a/apps/remix-ide/webpack.config.js +++ b/apps/remix-ide/webpack.config.js @@ -171,7 +171,7 @@ module.exports = config => { BROWSER: JSON.stringify(true), // Public team-owned Deno BFF origin. Keep overridable so test and // production can cut over independently without editing application code. - 'process.env.TRONIDE_GITHUB_BFF_ORIGIN': JSON.stringify(process.env.TRONIDE_GITHUB_BFF_ORIGIN || 'https://tronide-gh-oauth.redchar1992.deno.net'), + 'process.env.TRONIDE_GITHUB_BFF_ORIGIN': JSON.stringify(process.env.TRONIDE_GITHUB_BFF_ORIGIN || ''), 'process.env.TRON_PUBLIC_TRONGRID_API_KEY': JSON.stringify(process.env.TRON_PUBLIC_TRONGRID_API_KEY || ''), 'process.env.TRONSCAN_MAINNET_CONTRACT_API_URLS': JSON.stringify(process.env.TRONSCAN_MAINNET_CONTRACT_API_URLS || ''), 'process.env.TRONSCAN_NILE_CONTRACT_API_URLS': JSON.stringify(process.env.TRONSCAN_NILE_CONTRACT_API_URLS || ''), diff --git a/services/github-oauth/BFF_MIGRATION.md b/services/github-oauth/BFF_MIGRATION.md index bbcfaed65..16c40cb0b 100644 --- a/services/github-oauth/BFF_MIGRATION.md +++ b/services/github-oauth/BFF_MIGRATION.md @@ -1,4 +1,4 @@ -# GitHub OAuth BFF migration plan (`release/v2.3.3`) +# GitHub OAuth BFF migration plan ## Problem @@ -65,13 +65,14 @@ sequenceDiagram ## Deployment and cut-over -The GitLab frontend pipeline does **not** deploy `services/github-oauth`; Deno -must be deployed separately. Do not point production at the BFF frontend until -the service and secrets below are ready. +The frontend pipelines do **not** deploy `services/github-oauth`; Deno must be +deployed separately. Do not point production at the BFF frontend until the +service and secrets below are ready. -1. Complete ownership transfer of both the GitHub OAuth App and the Deno project - to the `tronweb3` team. Repository ownership alone does not transfer the Deno - project or its secrets. +1. Create or transfer the GitHub OAuth App and create the Deno project under the + `tronweb3` organization. Verify both resources, their secrets, and deployment + access are organization-controlled. Repository ownership alone changes + neither resource. 2. Attach Deno KV and configure: - `GITHUB_CLIENT_ID` - `GITHUB_CLIENT_SECRET` @@ -84,7 +85,8 @@ the service and secrets below are ready. `prompt=select_account`. 4. Update the GitHub OAuth App callback to the team-owned BFF `/callback`. 5. Set the frontend build variable `TRONIDE_GITHUB_BFF_ORIGIN` to the same - team-owned BFF origin, then deploy `release/v2.3.3` to the test environment; + team-owned BFF origin, then deploy this branch/current online build to a + test or preview environment; run connect, refresh, gist, public/private repository import, commit/push/pull, disconnect, and expiry checks. Confirm DevTools never contains a GitHub access token. @@ -104,5 +106,5 @@ rest of TronIDE. - REST and Git proxy path traversal, arbitrary hosts, redirects, and raw browser credentials are rejected. - Account selection is shown on every new OAuth connection. -- Test-environment build SHA matches the pushed `release/v2.3.3` SHA and all - required GitLab jobs pass. +- Test-environment build SHA matches the pushed PR SHA and all required GitHub + checks pass. diff --git a/services/github-oauth/README.md b/services/github-oauth/README.md index b91ef56b0..103fb0594 100644 --- a/services/github-oauth/README.md +++ b/services/github-oauth/README.md @@ -27,7 +27,8 @@ Configure the team-owned OAuth App with: | Authorization callback URL | `` ending in `/callback` | | Enable Device Flow | off | -The OAuth App and the Deno project are separate resources. Transfer and verify +The OAuth App and the Deno project are separate resources. Create or transfer +the OAuth App and create the Deno project under the organization, then verify both; transferring the source repository alone changes neither one. ## 2. Deno deployment @@ -41,7 +42,7 @@ deployctl deploy --project=tronide-gh-oauth main.ts ``` A linked repository may deploy the same entry point automatically. The main -TronIDE GitLab pipeline does not deploy this service. +TronIDE frontend pipeline does not deploy this service. ## 3. Environment variables @@ -91,7 +92,7 @@ OAuth. ```sh cd services/github-oauth deno task test -deno check --unstable main.ts +deno check main.ts ``` For a full local OAuth flow, create a separate development OAuth App and set its diff --git a/services/github-oauth/deno.json b/services/github-oauth/deno.json index 7369ed491..482ece71e 100644 --- a/services/github-oauth/deno.json +++ b/services/github-oauth/deno.json @@ -1,8 +1,8 @@ { "tasks": { - "dev": "deno run --unstable --allow-net --allow-env --allow-read --allow-write --watch main.ts", - "test": "deno test --unstable --allow-env main_test.ts", - "check": "deno fmt --check main.ts main_test.ts && deno check --unstable main.ts main_test.ts", + "dev": "deno run --allow-net --allow-env --allow-read --allow-write --watch main.ts", + "test": "deno test --allow-env main_test.ts", + "check": "deno fmt --check main.ts main_test.ts && deno check main.ts main_test.ts", "deploy": "deployctl deploy --project=tronide-gh-oauth main.ts" } } diff --git a/services/github-oauth/main.ts b/services/github-oauth/main.ts index 083d3e628..c791b0ab2 100644 --- a/services/github-oauth/main.ts +++ b/services/github-oauth/main.ts @@ -9,12 +9,10 @@ const CLIENT_ID = Deno.env.get("GITHUB_CLIENT_ID") ?? ""; const CLIENT_SECRET = Deno.env.get("GITHUB_CLIENT_SECRET") ?? ""; -const REDIRECT_URI = Deno.env.get("REDIRECT_URI") ?? - "https://tronide-gh-oauth.redchar1992.deno.net/callback"; +const REDIRECT_URI = Deno.env.get("REDIRECT_URI") ?? ""; const SESSION_ENCRYPTION_KEY = Deno.env.get("SESSION_ENCRYPTION_KEY") ?? ""; -const ALLOWED_ORIGINS = - (Deno.env.get("ALLOWED_ORIGINS") ?? "https://tronide.io") - .split(",").map((value) => value.trim()).filter(Boolean); +const ALLOWED_ORIGINS = (Deno.env.get("ALLOWED_ORIGINS") ?? "") + .split(",").map((value) => value.trim()).filter(Boolean); const GITHUB_SCOPE = Deno.env.get("GITHUB_SCOPE") ?? "gist repo"; const OAUTH_RATE_LIMIT = positiveInt(Deno.env.get("OAUTH_RATE_LIMIT"), 10); const API_RATE_LIMIT = positiveInt(Deno.env.get("API_RATE_LIMIT"), 120);