Skip to content

Commit a856cfa

Browse files
committed
[Personal-WP] Add GitHub OAuth integration for private repositories
Adds GitHub OAuth support to personal-wp, allowing blueprints to access private GitHub repositories. ## OAuth Flow - Added oauth.php for token exchange with GitHub - Added GitHubPrivateRepoAuthModal for authentication prompts - createGitAuthHeaders() provides auth headers, capturing token at call time (not creation time) to work after OAuth redirect ## Blueprint Preservation - Hash fragment blueprints converted to blueprint-url query params early in main.tsx to survive OAuth redirect - buildOAuthRedirectUrl() handles this conversion for the auth modal ## Error Handling - GitAuthenticationError detection in boot error handler - Early return in catch block prevents URL clearing after errors
1 parent 9ae7536 commit a856cfa

File tree

30 files changed

+2829
-1
lines changed

30 files changed

+2829
-1
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
$client_id = getenv('CLIENT_ID');
4+
if (array_key_exists('redirect', $_GET) && $_GET["redirect"] === "1") {
5+
http_response_code(302);
6+
// Always redirect to the current host, even if the redirect_uri is set to a different host.
7+
// Also, do not allow any custom path segments in the redirect_uri.
8+
$redirect_host = $_SERVER['HTTP_HOST'];
9+
$redirect_query = parse_url($_GET['redirect_uri'], PHP_URL_QUERY);
10+
$redirect_uri = 'https://' . $redirect_host . '?' . $redirect_query;
11+
$redirect_param = isset($_GET['redirect_uri']) ? "&redirect_uri=" . urlencode($redirect_uri) : '';
12+
header("Location: https://github.com/login/oauth/authorize?client_id={$client_id}&scope=repo" . $redirect_param);
13+
die();
14+
}
15+
16+
$api_endpoint = 'https://github.com/login/oauth/access_token';
17+
$data = [
18+
'client_id' => $client_id,
19+
'client_secret' => getenv('CLIENT_SECRET'),
20+
'code' => $_GET['code'],
21+
];
22+
23+
$ch = curl_init();
24+
curl_setopt($ch, CURLOPT_URL, $api_endpoint);
25+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
26+
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
27+
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
28+
curl_setopt($ch, CURLOPT_POST, true);
29+
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
30+
$result = curl_exec($ch);
31+
parse_str($result, $auth_data);
32+
33+
header('Content-Type: application/json');
34+
echo json_encode($auth_data);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Modal } from '../modal';
2+
import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store';
3+
import { setActiveModal } from '../../lib/state/redux/slice-ui';
4+
import { Icon } from '@wordpress/components';
5+
import { GitHubIcon } from '../../github/github';
6+
import css from '../../github/github-oauth-guard/style.module.css';
7+
import { staticAnalyzeGitHubURL } from '../../github/analyze-github-url';
8+
import { buildOAuthRedirectUrl } from '../../github/git-auth-helpers';
9+
10+
const OAUTH_FLOW_URL = 'oauth.php?redirect=1';
11+
12+
export function GitHubPrivateRepoAuthModal() {
13+
const dispatch = useAppDispatch();
14+
const repoUrl = useAppSelector((state) => state.ui.githubAuthRepoUrl);
15+
16+
if (!repoUrl) {
17+
return null;
18+
}
19+
20+
const { owner, repo } = staticAnalyzeGitHubURL(repoUrl);
21+
const displayRepoName = owner && repo ? `${owner}/${repo}` : repoUrl;
22+
23+
const urlParams = new URLSearchParams();
24+
urlParams.set('redirect_uri', buildOAuthRedirectUrl());
25+
const oauthUrl = `${OAUTH_FLOW_URL}&${urlParams.toString()}`;
26+
27+
return (
28+
<Modal
29+
title="Connect to GitHub"
30+
onRequestClose={() => dispatch(setActiveModal(null))}
31+
>
32+
<div>
33+
<p>
34+
This blueprint requires access to a private GitHub
35+
repository:
36+
</p>
37+
<p>
38+
<strong>
39+
<code>github.com/{displayRepoName}</code>
40+
</strong>
41+
</p>
42+
<p>
43+
If you have a GitHub account with access to this repository,
44+
you can connect it to continue.
45+
</p>
46+
47+
<p>
48+
<a
49+
aria-label="Connect your GitHub account"
50+
className={css.githubButton}
51+
href={oauthUrl}
52+
>
53+
<Icon icon={GitHubIcon} />
54+
Connect your GitHub account
55+
</a>
56+
</p>
57+
<p>
58+
<small>
59+
Your access token is stored only in memory and will be
60+
cleared when you close this tab.
61+
</small>
62+
</p>
63+
</div>
64+
</Modal>
65+
);
66+
}

packages/playground/personal-wp/src/components/layout/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import {
1212
PlaygroundViewport,
1313
} from '../playground-viewport';
1414
import { MissingSiteModal } from '../missing-site-modal';
15+
import { GitHubPrivateRepoAuthModal } from '../github-private-repo-auth-modal';
1516
import { modalSlugs } from '../../lib/state/redux/slice-ui';
1617
import { SiteManager } from '../site-manager';
18+
import { acquireOAuthTokenIfNeeded } from '../../github/acquire-oauth-token-if-needed';
19+
20+
acquireOAuthTokenIfNeeded();
1721

1822
const displayMode = getDisplayModeFromQuery();
1923
function getDisplayModeFromQuery(): DisplayMode {
@@ -71,6 +75,8 @@ function Modals() {
7175
return <StartErrorModal />;
7276
} else if (currentModal === modalSlugs.MISSING_SITE_PROMPT) {
7377
return <MissingSiteModal />;
78+
} else if (currentModal === modalSlugs.GITHUB_PRIVATE_REPO_AUTH) {
79+
return <GitHubPrivateRepoAuthModal />;
7480
}
7581

7682
return;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { setOAuthToken, oAuthState } from './state';
2+
import { oauthCode } from './github-oauth-guard';
3+
4+
export async function acquireOAuthTokenIfNeeded() {
5+
if (!oauthCode) {
6+
return;
7+
}
8+
9+
oAuthState.value = {
10+
...oAuthState.value,
11+
isAuthorizing: true,
12+
};
13+
14+
try {
15+
const response = await fetch('/oauth.php?code=' + oauthCode, {
16+
headers: {
17+
'Content-Type': 'application/json',
18+
Accept: 'application/json',
19+
},
20+
});
21+
const body = await response.json();
22+
setOAuthToken(body.access_token);
23+
24+
const url = new URL(window.location.href);
25+
url.searchParams.delete('code');
26+
window.history.replaceState({}, '', url.toString());
27+
} finally {
28+
oAuthState.value = {
29+
...oAuthState.value,
30+
isAuthorizing: false,
31+
};
32+
}
33+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { GitHubURLInformation } from './analyze-github-url';
2+
import { staticAnalyzeGitHubURL } from './analyze-github-url';
3+
4+
describe('staticAnalyzeGitHubURL', () => {
5+
it('should return correct GitHubURLInformation for a repo URL', () => {
6+
const url = 'https://github.com/owner/repo/';
7+
const expected: GitHubURLInformation = {
8+
owner: 'owner',
9+
repo: 'repo',
10+
type: 'repo',
11+
ref: undefined,
12+
path: '',
13+
pr: undefined,
14+
};
15+
expect(staticAnalyzeGitHubURL(url)).toEqual(expected);
16+
});
17+
18+
it('should return correct GitHubURLInformation for a PR URL', () => {
19+
const url = 'https://github.com/owner/repo/pull/123';
20+
const expected: GitHubURLInformation = {
21+
owner: 'owner',
22+
repo: 'repo',
23+
type: 'pr',
24+
ref: undefined,
25+
path: '',
26+
pr: 123,
27+
};
28+
expect(staticAnalyzeGitHubURL(url)).toEqual(expected);
29+
});
30+
31+
it('should throw an error for an invalid PR URL', () => {
32+
const url = 'https://github.com/owner/repo/pull/invalid';
33+
expect(() => staticAnalyzeGitHubURL(url)).toThrowError(
34+
'Invalid Pull Request number NaN parsed from the following GitHub URL: https://github.com/owner/repo/pull/invalid'
35+
);
36+
});
37+
38+
it('should return correct GitHubURLInformation for a branch URL', () => {
39+
const url = 'https://github.com/owner/repo/tree/branch/path/to/file';
40+
const expected: GitHubURLInformation = {
41+
owner: 'owner',
42+
repo: 'repo',
43+
type: 'branch',
44+
ref: 'branch',
45+
path: 'path/to/file',
46+
pr: undefined,
47+
};
48+
expect(staticAnalyzeGitHubURL(url)).toEqual(expected);
49+
});
50+
51+
it('should return correct GitHubURLInformation for a raw file URL', () => {
52+
const url =
53+
'https://raw.githubusercontent.com/owner/repo/branch/path/to/file.zip';
54+
const expected: GitHubURLInformation = {
55+
owner: 'owner',
56+
repo: 'repo',
57+
type: 'rawfile',
58+
ref: undefined,
59+
path: 'owner/repo/branch/path/to/file.zip',
60+
};
61+
expect(staticAnalyzeGitHubURL(url)).toEqual(expected);
62+
});
63+
64+
it('should return correct GitHubURLInformation for a repo URL', () => {
65+
const url = 'https://github.com/owner/repo';
66+
const expected: GitHubURLInformation = {
67+
owner: 'owner',
68+
repo: 'repo',
69+
type: 'repo',
70+
ref: undefined,
71+
path: '',
72+
};
73+
expect(staticAnalyzeGitHubURL(url)).toEqual(expected);
74+
});
75+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export type GitHubURLInformation = {
2+
owner?: string;
3+
repo?: string;
4+
type: 'pr' | 'repo' | 'branch' | 'rawfile' | 'unknown';
5+
ref?: string;
6+
path?: string;
7+
pr?: number;
8+
};
9+
export function staticAnalyzeGitHubURL(url: string): GitHubURLInformation {
10+
let urlObj;
11+
try {
12+
urlObj = new URL(url);
13+
} catch {
14+
return {
15+
type: 'unknown',
16+
};
17+
}
18+
const [owner, repo, ...rest] = urlObj.pathname
19+
.replace(/^\/+|\/+$/g, '')
20+
.split('/');
21+
22+
let pr,
23+
ref,
24+
type: GitHubURLInformation['type'] = 'unknown',
25+
path = '';
26+
if (urlObj.hostname === 'raw.githubusercontent.com') {
27+
type = 'rawfile';
28+
path = urlObj.pathname.substring(1);
29+
} else if (rest[0] === 'pull') {
30+
type = 'pr';
31+
pr = parseInt(rest[1]);
32+
if (isNaN(pr) || !pr) {
33+
throw new Error(
34+
`Invalid Pull Request number ${pr} parsed from the following GitHub URL: ${url}`
35+
);
36+
}
37+
} else if (['blob', 'tree'].includes(rest[0])) {
38+
type = 'branch';
39+
ref = rest[1];
40+
path = rest.slice(2).join('/');
41+
} else if (rest.length === 0) {
42+
type = 'repo';
43+
}
44+
45+
return { owner, repo, type, ref, path, pr };
46+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { createGitAuthHeaders } from './git-auth-helpers';
3+
import { oAuthState } from './state';
4+
5+
describe('createGitAuthHeaders', () => {
6+
beforeEach(() => {
7+
oAuthState.value = { token: '', isAuthorizing: false };
8+
});
9+
10+
describe('with GitHub token present', () => {
11+
beforeEach(() => {
12+
oAuthState.value = {
13+
token: 'gho_TestToken123',
14+
isAuthorizing: false,
15+
};
16+
});
17+
18+
it('includes Authorization header for github.com URLs', () => {
19+
const getHeaders = createGitAuthHeaders();
20+
const headers = getHeaders('https://github.com/user/repo');
21+
22+
expect(headers).toHaveProperty('Authorization');
23+
expect(headers.Authorization).toMatch(/^Basic /);
24+
expect(headers).toHaveProperty(
25+
'X-Cors-Proxy-Allowed-Request-Headers',
26+
'Authorization'
27+
);
28+
});
29+
30+
it('includes Authorization header for api.github.com URLs', () => {
31+
const getHeaders = createGitAuthHeaders();
32+
const headers = getHeaders('https://api.github.com/repos');
33+
34+
expect(headers).toHaveProperty('Authorization');
35+
});
36+
37+
it('does NOT include Authorization header for non-GitHub URLs', () => {
38+
const getHeaders = createGitAuthHeaders();
39+
40+
expect(getHeaders('https://gitlab.com/user/repo')).toEqual({});
41+
expect(getHeaders('https://bitbucket.org/user/repo')).toEqual({});
42+
});
43+
44+
it('does NOT include Authorization header for malicious URLs (security)', () => {
45+
const getHeaders = createGitAuthHeaders();
46+
47+
// github.com in path
48+
expect(getHeaders('https://evil.com/github.com/fake')).toEqual({});
49+
50+
// github.com in query parameter
51+
expect(getHeaders('https://evil.com?redirect=github.com')).toEqual(
52+
{}
53+
);
54+
55+
// look-alike domains
56+
expect(getHeaders('https://github.1485827954.workers.dev.evil.com')).toEqual({});
57+
expect(getHeaders('https://mygithub.com')).toEqual({});
58+
expect(getHeaders('https://fakegithub.com')).toEqual({});
59+
});
60+
});
61+
62+
describe('without GitHub token', () => {
63+
beforeEach(() => {
64+
oAuthState.value = { token: '', isAuthorizing: false };
65+
});
66+
67+
it('returns empty headers even for GitHub URLs', () => {
68+
const getHeaders = createGitAuthHeaders();
69+
70+
expect(getHeaders('https://github.com/user/repo')).toEqual({});
71+
});
72+
});
73+
74+
describe('token encoding', () => {
75+
it('encodes token correctly as Basic auth', () => {
76+
oAuthState.value = { token: 'test-token', isAuthorizing: false };
77+
const getHeaders = createGitAuthHeaders();
78+
const headers = getHeaders('https://github.com/user/repo');
79+
80+
const decoded = atob(headers.Authorization.replace('Basic ', ''));
81+
expect(decoded).toBe('test-token:');
82+
});
83+
84+
it('handles tokens with non-ASCII characters (UTF-8)', () => {
85+
// This would fail with plain btoa(): "characters outside of the Latin1 range"
86+
oAuthState.value = {
87+
token: 'test-token-ąñ-emoji-🔑',
88+
isAuthorizing: false,
89+
};
90+
const getHeaders = createGitAuthHeaders();
91+
const headers = getHeaders('https://github.com/user/repo');
92+
93+
expect(headers).toHaveProperty('Authorization');
94+
expect(headers.Authorization).toMatch(/^Basic /);
95+
96+
// Verify the encoding is valid base64
97+
const base64Part = headers.Authorization.replace('Basic ', '');
98+
expect(() => atob(base64Part)).not.toThrow();
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)