diff --git a/packages/playground/blueprints/src/lib/v1/compile.ts b/packages/playground/blueprints/src/lib/v1/compile.ts index b2b3b7bb89..d00038450c 100644 --- a/packages/playground/blueprints/src/lib/v1/compile.ts +++ b/packages/playground/blueprints/src/lib/v1/compile.ts @@ -83,6 +83,11 @@ export interface CompileBlueprintV1Options { * A filesystem to use for the blueprint. */ streamBundledFile?: StreamBundledFile; + /** + * Additional headers to pass to git operations. + * A function that returns headers based on the URL being accessed. + */ + gitAdditionalHeadersCallback?: (url: string) => Record; /** * Additional steps to add to the blueprint. */ @@ -142,6 +147,7 @@ function compileBlueprintJson( onBlueprintValidated = () => {}, corsProxy, streamBundledFile, + gitAdditionalHeadersCallback, additionalSteps, }: CompileBlueprintV1Options = {} ): CompiledBlueprintV1 { @@ -321,6 +327,7 @@ function compileBlueprintJson( totalProgressWeight, corsProxy, streamBundledFile, + gitAdditionalHeadersCallback, }) ); @@ -514,6 +521,11 @@ interface CompileStepArgsOptions { * A filesystem to use for the "blueprint" resource type. */ streamBundledFile?: StreamBundledFile; + /** + * Additional headers to pass to git operations. + * A function that returns headers based on the URL being accessed. + */ + gitAdditionalHeadersCallback?: (url: string) => Record; } /** @@ -532,6 +544,7 @@ function compileStep( totalProgressWeight, corsProxy, streamBundledFile, + gitAdditionalHeadersCallback, }: CompileStepArgsOptions ): { run: CompiledV1Step; step: S; resources: Array> } { const stepProgress = rootProgressTracker.stage( @@ -546,6 +559,7 @@ function compileStep( semaphore, corsProxy, streamBundledFile, + gitAdditionalHeadersCallback, }); } args[key] = value; diff --git a/packages/playground/blueprints/src/lib/v1/resources.spec.ts b/packages/playground/blueprints/src/lib/v1/resources.spec.ts index 3eceae1996..33255c6613 100644 --- a/packages/playground/blueprints/src/lib/v1/resources.spec.ts +++ b/packages/playground/blueprints/src/lib/v1/resources.spec.ts @@ -3,7 +3,7 @@ import { GitDirectoryResource, BundledResource, } from './resources'; -import { expect, describe, it, vi, beforeEach } from 'vitest'; +import { expect, describe, it, vi, beforeEach, afterEach } from 'vitest'; import { StreamedFile } from '@php-wasm/stream-compression'; import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises'; import { tmpdir } from 'os'; @@ -232,6 +232,95 @@ describe('GitDirectoryResource', () => { expect(name).toBe('https-github.com-WordPress-link-manager-trunk'); }); }); + + describe('CORS handling', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should unwrap CORS URL in GitAuthenticationError', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const githubUrl = 'https://github.com/user/private-repo'; + const resource = new GitDirectoryResource( + { + resource: 'git:directory', + url: githubUrl, + ref: 'main', + }, + undefined, + { + corsProxy: 'https://cors-proxy.com/', + } + ); + + await expect(resource.resolve()).rejects.toMatchObject({ + name: 'GitAuthenticationError', + repoUrl: githubUrl, + status: 401, + }); + }); + + it('should preserve GitHub URL in GitAuthenticationError without CORS proxy', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const githubUrl = 'https://github.com/user/private-repo'; + const resource = new GitDirectoryResource({ + resource: 'git:directory', + url: githubUrl, + ref: 'main', + }); + + await expect(resource.resolve()).rejects.toMatchObject({ + name: 'GitAuthenticationError', + repoUrl: githubUrl, + status: 401, + }); + }); + + it('should call gitAdditionalHeadersCallback without CORS proxy', async () => { + const githubUrl = 'https://github.com/user/private-repo'; + const headerCallback = vi.fn().mockReturnValue({ + Authorization: 'Bearer test-token', + }); + + const resource = new GitDirectoryResource( + { + resource: 'git:directory', + url: githubUrl, + ref: 'main', + }, + undefined, + { + additionalHeaders: headerCallback, + } + ); + + // Call resolve - it will fail but that's okay, we just want to verify the callback + try { + await resource.resolve(); + } catch { + // Expected to fail - we're not mocking the entire git resolution + } + + // Verify the callback was called with the GitHub URL (not CORS-wrapped) + expect(headerCallback).toHaveBeenCalledWith(githubUrl); + }); + }); }); describe('BlueprintResource', () => { diff --git a/packages/playground/blueprints/src/lib/v1/resources.ts b/packages/playground/blueprints/src/lib/v1/resources.ts index c20c06b1c7..93ad66957c 100644 --- a/packages/playground/blueprints/src/lib/v1/resources.ts +++ b/packages/playground/blueprints/src/lib/v1/resources.ts @@ -7,6 +7,7 @@ import type { FileTree, UniversalPHP } from '@php-wasm/universal'; import type { Semaphore } from '@php-wasm/util'; import { randomFilename } from '@php-wasm/util'; import { + GitAuthenticationError, listDescendantFiles, listGitFiles, resolveCommitHash, @@ -157,12 +158,16 @@ export abstract class Resource { progress, corsProxy, streamBundledFile, + gitAdditionalHeadersCallback, }: { /** Optional semaphore to limit concurrent downloads */ semaphore?: Semaphore; progress?: ProgressTracker; corsProxy?: string; streamBundledFile?: StreamBundledFile; + gitAdditionalHeadersCallback?: ( + url: string + ) => Record; } ): Resource { let resource: Resource; @@ -185,6 +190,7 @@ export abstract class Resource { case 'git:directory': resource = new GitDirectoryResource(ref, progress, { corsProxy, + additionalHeaders: gitAdditionalHeadersCallback, }); break; case 'literal:directory': @@ -556,12 +562,18 @@ export class UrlResource extends FetchResource { */ export class GitDirectoryResource extends Resource { private reference: GitDirectoryReference; - private options?: { corsProxy?: string }; + private options?: { + corsProxy?: string; + additionalHeaders?: (url: string) => Record; + }; constructor( reference: GitDirectoryReference, _progress?: ProgressTracker, - options?: { corsProxy?: string } + options?: { + corsProxy?: string; + additionalHeaders?: (url: string) => Record; + } ) { super(); this.reference = reference; @@ -570,51 +582,77 @@ export class GitDirectoryResource extends Resource { } async resolve() { + const additionalHeaders = + this.options?.additionalHeaders?.(this.reference.url) ?? {}; + const repoUrl = this.options?.corsProxy ? `${this.options.corsProxy}${this.reference.url}` : this.reference.url; - const commitHash = await resolveCommitHash(repoUrl, { - value: this.reference.ref, - type: this.reference.refType ?? 'infer', - }); - const allFiles = await listGitFiles(repoUrl, commitHash); - - const requestedPath = (this.reference.path ?? '').replace(/^\/+/, ''); - const filesToClone = listDescendantFiles(allFiles, requestedPath); - const checkout = await sparseCheckout( - repoUrl, - commitHash, - filesToClone, - { - withObjects: this.reference['.git'], - } - ); - let files = checkout.files; + try { + const commitHash = await resolveCommitHash( + repoUrl, + { + value: this.reference.ref, + type: this.reference.refType ?? 'infer', + }, + additionalHeaders + ); + const allFiles = await listGitFiles( + repoUrl, + commitHash, + additionalHeaders + ); - // Remove the path prefix from the cloned file names. - files = mapKeys(files, (name) => - name.substring(requestedPath.length).replace(/^\/+/, '') - ); - if (this.reference['.git']) { - const gitFiles = await createDotGitDirectory({ - repoUrl: this.reference.url, + const requestedPath = (this.reference.path ?? '').replace( + /^\/+/, + '' + ); + const filesToClone = listDescendantFiles(allFiles, requestedPath); + const checkout = await sparseCheckout( + repoUrl, commitHash, - ref: this.reference.ref, - refType: this.reference.refType, - objects: checkout.objects ?? [], - fileOids: checkout.fileOids ?? {}, - pathPrefix: requestedPath, - }); - files = { - ...gitFiles, - ...files, + filesToClone, + { + withObjects: this.reference['.git'], + additionalHeaders, + } + ); + let files = checkout.files; + + // Remove the path prefix from the cloned file names. + files = mapKeys(files, (name) => + name.substring(requestedPath.length).replace(/^\/+/, '') + ); + if (this.reference['.git']) { + const gitFiles = await createDotGitDirectory({ + repoUrl: this.reference.url, + commitHash, + ref: this.reference.ref, + refType: this.reference.refType, + objects: checkout.objects ?? [], + fileOids: checkout.fileOids ?? {}, + pathPrefix: requestedPath, + }); + files = { + ...gitFiles, + ...files, + }; + } + return { + name: this.filename, + files, }; + } catch (error) { + if (error instanceof GitAuthenticationError) { + // Unwrap and re-throw with the original URL (without CORS proxy) + throw new GitAuthenticationError( + this.reference.url, + error.status + ); + } + throw error; } - return { - name: this.filename, - files, - }; } /** diff --git a/packages/playground/client/src/blueprints-v1-handler.ts b/packages/playground/client/src/blueprints-v1-handler.ts index d989091cd1..4ea030750a 100644 --- a/packages/playground/client/src/blueprints-v1-handler.ts +++ b/packages/playground/client/src/blueprints-v1-handler.ts @@ -21,6 +21,7 @@ export class BlueprintsV1Handler { onBlueprintValidated, onBlueprintStepCompleted, corsProxy, + gitAdditionalHeadersCallback, mounts, sapiName, scope, @@ -72,6 +73,7 @@ export class BlueprintsV1Handler { onStepCompleted: onBlueprintStepCompleted, onBlueprintValidated, corsProxy, + gitAdditionalHeadersCallback, }); await runBlueprintV1Steps(compiled, playground); } diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index 4d97aca96a..834b7f97cb 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -85,6 +85,11 @@ export interface StartPlaygroundOptions { * your Blueprint to replace all cross-origin URLs with the proxy URL. */ corsProxy?: string; + /** + * Additional headers to pass to git operations. + * A function that returns headers based on the URL being accessed. + */ + gitAdditionalHeadersCallback?: (url: string) => Record; /** * The version of the SQLite driver to use. * Defaults to the latest development version. diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts b/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts index 1026bdd5a4..60acfd3d2f 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts @@ -3,7 +3,9 @@ import { sparseCheckout, listGitFiles, resolveCommitHash, + type GitAdditionalHeaders, } from './git-sparse-checkout'; +import { vi } from 'vitest'; describe('listRefs', () => { it('should return the latest commit hash for a given ref', async () => { @@ -190,3 +192,123 @@ describe('listGitFiles', () => { ); }); }); + +describe('gitAdditionalHeaders', () => { + const repoUrl = 'https://github.com/WordPress/wordpress-playground.git'; + + it('should successfully fetch when headers is empty object', async () => { + const headers: GitAdditionalHeaders = {}; + + const refs = await listGitRefs(repoUrl, 'refs/heads/trunk', headers); + + expect(refs).toHaveProperty('refs/heads/trunk'); + expect(refs['refs/heads/trunk']).toMatch(/^[a-f0-9]{40}$/); + }); + + it('should pass headers through the full call chain', async () => { + const headers: GitAdditionalHeaders = {}; + + await resolveCommitHash( + repoUrl, + { value: 'trunk', type: 'branch' }, + headers + ); + + expect(headers).toBeDefined(); + }); +}); + +describe('authentication error handling', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should throw GitAuthenticationError for 401 responses', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const headers: GitAdditionalHeaders = { + Authorization: 'Bearer token', + }; + + await expect( + listGitRefs( + 'https://github.com/user/private-repo', + 'refs/heads/main', + headers + ) + ).rejects.toThrow( + 'Authentication required to access private repository' + ); + }); + + it('should throw GitAuthenticationError for 403 responses', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + + const headers: GitAdditionalHeaders = { + Authorization: 'Bearer token', + }; + + await expect( + listGitRefs( + 'https://github.com/user/private-repo', + 'refs/heads/main', + headers + ) + ).rejects.toThrow( + 'Authentication required to access private repository' + ); + }); + + it('should throw generic error for 404 even with auth token (ambiguous: repo not found OR no access)', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const headers: GitAdditionalHeaders = { + Authorization: 'Bearer token', + }; + + await expect( + listGitRefs( + 'https://github.com/user/repo-or-no-access', + 'refs/heads/main', + headers + ) + ).rejects.toThrow( + 'Failed to fetch git refs from https://github.com/user/repo-or-no-access: 404 Not Found' + ); + }); + + it('should throw generic error for 404 without auth token', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect( + listGitRefs( + 'https://github.com/user/nonexistent-repo', + 'refs/heads/main' + ) + ).rejects.toThrow( + 'Failed to fetch git refs from https://github.com/user/nonexistent-repo: 404 Not Found' + ); + }); +}); diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index a977fc33bf..dc9cbfb0a9 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -30,6 +30,20 @@ if (typeof globalThis.Buffer === 'undefined') { globalThis.Buffer = BufferPolyfill; } +/** + * Custom error class for git authentication failures. + */ +export class GitAuthenticationError extends Error { + constructor(public repoUrl: string, public status: number) { + super( + `Authentication required to access private repository: ${repoUrl}` + ); + this.name = 'GitAuthenticationError'; + } +} + +export type GitAdditionalHeaders = Record; + /** * Downloads specific files from a git repository. * It uses the git protocol over HTTP to fetch the files. It only uses @@ -67,14 +81,22 @@ export async function sparseCheckout( filesPaths: string[], options?: { withObjects?: boolean; + additionalHeaders?: GitAdditionalHeaders; } ): Promise { - const treesPack = await fetchWithoutBlobs(repoUrl, commitHash); + const additionalHeaders = options?.additionalHeaders || {}; + const treesPack = await fetchWithoutBlobs( + repoUrl, + commitHash, + additionalHeaders + ); const objects = await resolveObjects(treesPack.idx, commitHash, filesPaths); const blobOids = filesPaths.map((path) => objects[path].oid); const blobsPack = - blobOids.length > 0 ? await fetchObjects(repoUrl, blobOids) : null; + blobOids.length > 0 + ? await fetchObjects(repoUrl, blobOids, additionalHeaders) + : null; const fetchedPaths: Record = {}; await Promise.all( @@ -177,9 +199,14 @@ const FULL_SHA_REGEX = /^[0-9a-f]{40}$/i; */ export async function listGitFiles( repoUrl: string, - commitHash: string + commitHash: string, + additionalHeaders: GitAdditionalHeaders = {} ): Promise { - const treesPack = await fetchWithoutBlobs(repoUrl, commitHash); + const treesPack = await fetchWithoutBlobs( + repoUrl, + commitHash, + additionalHeaders + ); const rootTree = await resolveAllObjects(treesPack.idx, commitHash); if (!rootTree?.object) { return []; @@ -195,13 +222,17 @@ export async function listGitFiles( * @param ref The branch name or commit hash. * @returns The commit hash. */ -export async function resolveCommitHash(repoUrl: string, ref: GitRef) { +export async function resolveCommitHash( + repoUrl: string, + ref: GitRef, + additionalHeaders: GitAdditionalHeaders = {} +) { const parsed = await parseGitRef(repoUrl, ref); if (parsed.resolvedOid) { return parsed.resolvedOid; } - const oid = await fetchRefOid(repoUrl, parsed.refname); + const oid = await fetchRefOid(repoUrl, parsed.refname, additionalHeaders); if (!oid) { throw new Error(`Git ref "${parsed.refname}" not found at ${repoUrl}`); } @@ -239,7 +270,8 @@ function gitTreeToFileTree(tree: GitTree): GitFileTree[] { */ export async function listGitRefs( repoUrl: string, - fullyQualifiedBranchPrefix: string + fullyQualifiedBranchPrefix: string, + additionalHeaders: GitAdditionalHeaders = {} ) { const packbuffer = Buffer.from( (await collect([ @@ -260,10 +292,20 @@ export async function listGitRefs( 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, 'Git-Protocol': 'version=2', + ...additionalHeaders, }, body: packbuffer as any, }); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new GitAuthenticationError(repoUrl, response.status); + } + throw new Error( + `Failed to fetch git refs from ${repoUrl}: ${response.status} ${response.statusText}` + ); + } + const refs: Record = {}; for await (const line of parseGitResponseLines(response)) { const spaceAt = line.indexOf(' '); @@ -376,8 +418,12 @@ async function parseGitRef( } } -async function fetchRefOid(repoUrl: string, refname: string) { - const refs = await listGitRefs(repoUrl, refname); +async function fetchRefOid( + repoUrl: string, + refname: string, + additionalHeaders?: GitAdditionalHeaders +) { + const refs = await listGitRefs(repoUrl, refname, additionalHeaders); const candidates = [refname, `${refname}^{}`]; for (const candidate of candidates) { const sanitized = candidate.trim(); @@ -388,7 +434,11 @@ async function fetchRefOid(repoUrl: string, refname: string) { return null; } -async function fetchWithoutBlobs(repoUrl: string, commitHash: string) { +async function fetchWithoutBlobs( + repoUrl: string, + commitHash: string, + additionalHeaders?: Record +) { const packbuffer = Buffer.from( (await collect([ GitPktLine.encode( @@ -409,10 +459,20 @@ async function fetchWithoutBlobs(repoUrl: string, commitHash: string) { Accept: 'application/x-git-upload-pack-advertisement', 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, + ...additionalHeaders, }, body: packbuffer as any, }); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new GitAuthenticationError(repoUrl, response.status); + } + throw new Error( + `Failed to fetch git objects from ${repoUrl}: ${response.status} ${response.statusText}` + ); + } + const iterator = streamToIterator(response.body!); const parsed = await parseUploadPackResponse(iterator); const packfile = Buffer.from((await collect(parsed.packfile)) as any); @@ -539,7 +599,11 @@ async function resolveObjects( } // Request oid for each resolvedRef -async function fetchObjects(url: string, objectHashes: string[]) { +async function fetchObjects( + url: string, + objectHashes: string[], + additionalHeaders?: Record +) { const packbuffer = Buffer.from( (await collect([ ...objectHashes.map((objectHash) => @@ -558,10 +622,20 @@ async function fetchObjects(url: string, objectHashes: string[]) { Accept: 'application/x-git-upload-pack-advertisement', 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, + ...additionalHeaders, }, body: packbuffer as any, }); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new GitAuthenticationError(url, response.status); + } + throw new Error( + `Failed to fetch git objects from ${url}: ${response.status} ${response.statusText}` + ); + } + const iterator = streamToIterator(response.body!); const parsed = await parseUploadPackResponse(iterator); const packfile = Buffer.from((await collect(parsed.packfile)) as any); diff --git a/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx new file mode 100644 index 0000000000..6d0a75061b --- /dev/null +++ b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx @@ -0,0 +1,68 @@ +import { Modal } from '../modal'; +import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store'; +import { setActiveModal } from '../../lib/state/redux/slice-ui'; +import { Icon } from '@wordpress/components'; +import { GitHubIcon } from '../../github/github'; +import css from '../../github/github-oauth-guard/style.module.css'; +import { staticAnalyzeGitHubURL } from '../../github/analyze-github-url'; + +const OAUTH_FLOW_URL = 'oauth.php?redirect=1'; + +export function GitHubPrivateRepoAuthModal() { + const dispatch = useAppDispatch(); + const repoUrl = useAppSelector((state) => state.ui.githubAuthRepoUrl); + + if (!repoUrl) { + return null; + } + + const { owner, repo } = staticAnalyzeGitHubURL(repoUrl); + const displayRepoName = owner && repo ? `${owner}/${repo}` : repoUrl; + + const redirectUrl = new URL(window.location.href); + redirectUrl.searchParams.delete('modal'); + + const urlParams = new URLSearchParams(); + urlParams.set('redirect_uri', redirectUrl.toString()); + const oauthUrl = `${OAUTH_FLOW_URL}&${urlParams.toString()}`; + + return ( + dispatch(setActiveModal(null))} + > +
+

+ This blueprint requires access to a private GitHub + repository: +

+

+ + github.com/{displayRepoName} + +

+

+ If you have a GitHub account with access to this repository, + you can connect it to continue. +

+ +

+ + + Connect your GitHub account + +

+

+ + Your access token is stored only in memory and will be + cleared when you close this tab. + +

+
+
+ ); +} diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 9523bd0946..4ef23807bd 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -31,6 +31,7 @@ import { PreviewPRModal } from '../../github/preview-pr'; import { MissingSiteModal } from '../missing-site-modal'; import { RenameSiteModal } from '../rename-site-modal'; import { SaveSiteModal } from '../save-site-modal'; +import { GitHubPrivateRepoAuthModal } from '../github-private-repo-auth-modal'; acquireOAuthTokenIfNeeded(); @@ -41,6 +42,7 @@ export const modalSlugs = { IMPORT_FORM: 'import-form', GITHUB_IMPORT: 'github-import', GITHUB_EXPORT: 'github-export', + GITHUB_PRIVATE_REPO_AUTH: 'github-private-repo-auth', PREVIEW_PR_WP: 'preview-pr-wordpress', PREVIEW_PR_GUTENBERG: 'preview-pr-gutenberg', MISSING_SITE_PROMPT: 'missing-site-prompt', @@ -216,6 +218,8 @@ function Modals(blueprint: BlueprintV1Declaration) { return ; } else if (currentModal === modalSlugs.SAVE_SITE) { return ; + } else if (currentModal === modalSlugs.GITHUB_PRIVATE_REPO_AUTH) { + return ; } if (query.get('gh-ensure-auth') === 'yes') { diff --git a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx index d7a3080dd1..f4aa62517c 100644 --- a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx +++ b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx @@ -25,15 +25,14 @@ export async function acquireOAuthTokenIfNeeded() { }); const body = await response.json(); setOAuthToken(body.access_token); + + const url = new URL(window.location.href); + url.searchParams.delete('code'); + window.history.replaceState({}, '', url.toString()); } finally { oAuthState.value = { ...oAuthState.value, isAuthorizing: false, }; } - - // Remove the ?code=... from the URL - const url = new URL(window.location.href); - url.searchParams.delete('code'); - window.history.replaceState(null, '', url.toString()); } diff --git a/packages/playground/website/src/github/git-auth-helpers.spec.ts b/packages/playground/website/src/github/git-auth-helpers.spec.ts new file mode 100644 index 0000000000..63313009b1 --- /dev/null +++ b/packages/playground/website/src/github/git-auth-helpers.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createGitAuthHeaders } from './git-auth-helpers'; +import { oAuthState } from './state'; + +describe('createGitAuthHeaders', () => { + beforeEach(() => { + oAuthState.value = { token: '', isAuthorizing: false }; + }); + + describe('with GitHub token present', () => { + beforeEach(() => { + oAuthState.value = { + token: 'gho_TestToken123', + isAuthorizing: false, + }; + }); + + it('includes Authorization header for github.com URLs', () => { + const getHeaders = createGitAuthHeaders(); + const headers = getHeaders('https://github.com/user/repo'); + + expect(headers).toHaveProperty('Authorization'); + expect(headers.Authorization).toMatch(/^Basic /); + expect(headers).toHaveProperty( + 'X-Cors-Proxy-Allowed-Request-Headers', + 'Authorization' + ); + }); + + it('includes Authorization header for api.github.com URLs', () => { + const getHeaders = createGitAuthHeaders(); + const headers = getHeaders('https://api.github.com/repos'); + + expect(headers).toHaveProperty('Authorization'); + }); + + it('does NOT include Authorization header for non-GitHub URLs', () => { + const getHeaders = createGitAuthHeaders(); + + expect(getHeaders('https://gitlab.com/user/repo')).toEqual({}); + expect(getHeaders('https://bitbucket.org/user/repo')).toEqual({}); + }); + + it('does NOT include Authorization header for malicious URLs (security)', () => { + const getHeaders = createGitAuthHeaders(); + + // github.com in path + expect(getHeaders('https://evil.com/github.com/fake')).toEqual({}); + + // github.com in query parameter + expect(getHeaders('https://evil.com?redirect=github.com')).toEqual( + {} + ); + + // look-alike domains + expect(getHeaders('https://github.com.evil.com')).toEqual({}); + expect(getHeaders('https://mygithub.com')).toEqual({}); + expect(getHeaders('https://fakegithub.com')).toEqual({}); + }); + }); + + describe('without GitHub token', () => { + beforeEach(() => { + oAuthState.value = { token: '', isAuthorizing: false }; + }); + + it('returns empty headers even for GitHub URLs', () => { + const getHeaders = createGitAuthHeaders(); + + expect(getHeaders('https://github.com/user/repo')).toEqual({}); + }); + }); + + describe('token encoding', () => { + it('encodes token correctly as Basic auth', () => { + oAuthState.value = { token: 'test-token', isAuthorizing: false }; + const getHeaders = createGitAuthHeaders(); + const headers = getHeaders('https://github.com/user/repo'); + + const decoded = atob(headers.Authorization.replace('Basic ', '')); + expect(decoded).toBe('test-token:'); + }); + + it('handles tokens with non-ASCII characters (UTF-8)', () => { + // This would fail with plain btoa(): "characters outside of the Latin1 range" + oAuthState.value = { + token: 'test-token-ąñ-emoji-🔑', + isAuthorizing: false, + }; + const getHeaders = createGitAuthHeaders(); + const headers = getHeaders('https://github.com/user/repo'); + + expect(headers).toHaveProperty('Authorization'); + expect(headers.Authorization).toMatch(/^Basic /); + + // Verify the encoding is valid base64 + const base64Part = headers.Authorization.replace('Basic ', ''); + expect(() => atob(base64Part)).not.toThrow(); + }); + }); +}); diff --git a/packages/playground/website/src/github/git-auth-helpers.ts b/packages/playground/website/src/github/git-auth-helpers.ts new file mode 100644 index 0000000000..a6fc250438 --- /dev/null +++ b/packages/playground/website/src/github/git-auth-helpers.ts @@ -0,0 +1,42 @@ +import { oAuthState } from './state'; + +function isGitHubUrl(url: string): boolean { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + return hostname === 'github.com' || hostname === 'api.github.com'; + } catch { + return false; + } +} + +export function shouldShowGitHubAuthModal(url: string | undefined): boolean { + return !!url && isGitHubUrl(url); +} + +export function createGitAuthHeaders(): ( + url: string +) => Record { + const token = oAuthState.value.token; + + return (url: string): Record => { + if (!token || !isGitHubUrl(url)) { + return {}; + } + + // Avoid InvalidCharacterError from btoa() with non-Latin1 characters + const encoder = new TextEncoder(); + const data = encoder.encode(`${token}:`); + const binary = []; + for (let i = 0; i < data.length; i++) { + binary.push(String.fromCharCode(data[i])); + } + const encodedToken = btoa(binary.join('')); + + return { + Authorization: `Basic ${encodedToken}`, + // Tell a CORS proxy to forward the Authorization header + 'X-Cors-Proxy-Allowed-Request-Headers': 'Authorization', + }; + }; +} diff --git a/packages/playground/website/src/github/github-oauth-guard/index.tsx b/packages/playground/website/src/github/github-oauth-guard/index.tsx index d409d8f84e..774980ee28 100644 --- a/packages/playground/website/src/github/github-oauth-guard/index.tsx +++ b/packages/playground/website/src/github/github-oauth-guard/index.tsx @@ -58,7 +58,9 @@ export default function GitHubOAuthGuard({ } const urlParams = new URLSearchParams(); - urlParams.set('redirect_uri', window.location.href); + const cleanUrl = new URL(window.location.href); + cleanUrl.searchParams.delete('code'); + urlParams.set('redirect_uri', cleanUrl.toString()); const oauthUrl = `${OAUTH_FLOW_URL}&${urlParams.toString()}`; return ( + ) => { + state.githubAuthRepoUrl = action.payload; + }, setOffline: (state, action: PayloadAction) => { state.offline = action.payload; }, @@ -122,7 +132,8 @@ export const listenToOnlineOfflineEventsMiddleware: Middleware = */ if ( query.get('modal') === 'error-report' || - query.get('modal') === 'save-site' + query.get('modal') === 'save-site' || + query.get('modal') === 'github-private-repo-auth' ) { setTimeout(() => { store.dispatch(uiSlice.actions.setActiveModal(null)); @@ -135,6 +146,7 @@ export const listenToOnlineOfflineEventsMiddleware: Middleware = export const { setActiveModal, setActiveSiteError, + setGitHubAuthRepoUrl, setOffline, setSiteManagerOpen, setSiteManagerSection, diff --git a/packages/playground/website/vite.oauth.ts b/packages/playground/website/vite.oauth.ts index ed665fdda5..b2ea337504 100644 --- a/packages/playground/website/vite.oauth.ts +++ b/packages/playground/website/vite.oauth.ts @@ -18,7 +18,7 @@ export const oAuthMiddleware = async ( if (query.get('redirect') === '1') { const params: Record = { client_id: CLIENT_ID!, - scope: 'public_repo', + scope: 'repo', }; if (query.has('redirect_uri')) { params.redirect_uri = query.get('redirect_uri')!;