Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,26 @@ jobs:
- name: Setup Pages
uses: actions/configure-pages@v5

- name: Validate required secrets
env:
GITHUB_CLIENT_ID: ${{ secrets.NEXT_PUBLIC_GITHUB_CLIENT_ID }}
OAUTH_PROXY_URL: ${{ secrets.NEXT_PUBLIC_GITHUB_OAUTH_PROXY_URL }}
run: |
missing=()
[[ -z "$GITHUB_CLIENT_ID" ]] && missing+=("NEXT_PUBLIC_GITHUB_CLIENT_ID")
[[ -z "$OAUTH_PROXY_URL" ]] && missing+=("NEXT_PUBLIC_GITHUB_OAUTH_PROXY_URL")
if [[ ${#missing[@]} -gt 0 ]]; then
echo "Error: the following required secrets are missing or empty:"
for s in "${missing[@]}"; do echo " - $s"; done
exit 1
fi

- name: Build Next.js app
# This builds all packages, including the Next.js app
run: pnpm turbo build
env:
NEXT_PUBLIC_GITHUB_CLIENT_ID: ${{ secrets.NEXT_PUBLIC_GITHUB_CLIENT_ID }}
NEXT_PUBLIC_GITHUB_OAUTH_PROXY_URL: ${{ secrets.NEXT_PUBLIC_GITHUB_OAUTH_PROXY_URL }}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- **GitHub OAuth token exchange**: Replaced the public third-party `corsproxy.io` proxy with a first-party Cloudflare Worker for the device flow token exchange. The `access_token` (full `repo` scope) no longer transits untrusted external infrastructure. CORS is restricted to the GitHub Pages origin on the Worker. `requestDeviceCode` continues using the public proxy as it returns only a `device_code` with no tokens.

## [1.2.0] - 2026-05-31

### Added
Expand Down
2 changes: 2 additions & 0 deletions apps/github-oauth-proxy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.dev.vars
node_modules/
15 changes: 15 additions & 0 deletions apps/github-oauth-proxy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@md-latex/github-oauth-proxy",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241230.0",
"typescript": "^5",
"wrangler": "^3"
}
}
102 changes: 102 additions & 0 deletions apps/github-oauth-proxy/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
interface Env {
ALLOWED_ORIGIN: string;
}

interface TokenRequest {
client_id: string;
device_code: string;
grant_type: string;
}

const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get('Origin') ?? '';
const allowedOrigin = env.ALLOWED_ORIGIN;

if (request.method === 'OPTIONS') {
if (origin === allowedOrigin) {
return new Response(null, { status: 204, headers: corsHeaders(allowedOrigin) });
}
return new Response(null, { status: 403 });
}

if (request.method !== 'POST') {
return jsonResponse({ error: 'method_not_allowed' }, 405);
}

if (origin !== allowedOrigin) {
return jsonResponse({ error: 'forbidden' }, 403);
}

let body: TokenRequest;
try {
body = (await request.json()) as TokenRequest;
} catch {
return jsonResponse(
{ error: 'invalid_request', error_description: 'Request body must be valid JSON.' },
400,
allowedOrigin,
);
}

const { client_id, device_code, grant_type } = body;
if (!client_id || !device_code || !grant_type) {
return jsonResponse(
{ error: 'invalid_request', error_description: 'Missing required fields: client_id, device_code, grant_type.' },
400,
allowedOrigin,
);
}
Comment thread
iamvirul marked this conversation as resolved.
if (grant_type !== 'urn:ietf:params:oauth:grant-type:device_code') {
return jsonResponse(
{ error: 'invalid_request', error_description: 'Invalid grant_type for device flow.' },
400,
allowedOrigin,
);
}

try {
const githubRes = await fetch(GITHUB_TOKEN_URL, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id, device_code, grant_type }),
});

const data = (await githubRes.json()) as Record<string, unknown>;
return jsonResponse(data, githubRes.status, allowedOrigin);
} catch {
return jsonResponse(
{ error: 'upstream_error', error_description: 'Failed to reach GitHub OAuth endpoint.' },
502,
allowedOrigin,
);
}
},
};

function corsHeaders(origin: string): Record<string, string> {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
};
}

function jsonResponse(
body: Record<string, unknown>,
status: number,
allowedOrigin?: string,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Pragma': 'no-cache',
...(allowedOrigin ? corsHeaders(allowedOrigin) : {}),
},
Comment thread
iamvirul marked this conversation as resolved.
});
}
15 changes: 15 additions & 0 deletions apps/github-oauth-proxy/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ES2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
8 changes: 8 additions & 0 deletions apps/github-oauth-proxy/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name = "md-latex-github-oauth-proxy"
main = "src/index.ts"
compatibility_date = "2024-11-01"

[vars]
# Restrict CORS to the GitHub Pages origin.
# Override this via `wrangler secret put ALLOWED_ORIGIN` for production.
ALLOWED_ORIGIN = "https://opendevflow.github.io"
41 changes: 24 additions & 17 deletions apps/web/lib/githubAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@ export interface DeviceCodeResponse {
const CLIENT_ID = process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID || '';
const CORS_PROXY = 'https://corsproxy.io/?';

// First-party Cloudflare Worker that performs the token exchange server-side so
// the access_token never transits public third-party proxy infrastructure.
const OAUTH_PROXY_URL = process.env.NEXT_PUBLIC_GITHUB_OAUTH_PROXY_URL || '';

/**
* Initiates the Device Authorization Flow
* Initiates the Device Authorization Flow.
* Uses a public CORS proxy — only a device_code and user_code are returned here,
* no access tokens, so third-party transit risk is low.
*/
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
if (!CLIENT_ID) {
throw new Error('GitHub Client ID is not configured.');
}

const targetUrl = 'https://github.com/login/device/code';
// Use CORS proxy to bypass GitHub's lack of CORS on OAuth endpoints
const url = `${CORS_PROXY}${encodeURIComponent(targetUrl)}`;

const response = await fetch(url, {
method: 'POST',
headers: {
Expand All @@ -46,31 +51,35 @@ export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
}

/**
* Polls GitHub for the access token until the user authorizes or it expires.
* Polls for the access token via the first-party OAuth proxy Worker.
* The Worker forwards the request to GitHub server-side so the access_token
* is never exposed to public third-party infrastructure.
*/
export async function pollForToken(
deviceCode: string,
intervalSeconds: number,
isCancelled: () => boolean
): Promise<string> {
if (!OAUTH_PROXY_URL) {
throw new Error(
'GitHub OAuth proxy URL is not configured. Set NEXT_PUBLIC_GITHUB_OAUTH_PROXY_URL.'
);
}
Comment thread
iamvirul marked this conversation as resolved.
if (!CLIENT_ID) {
throw new Error('GitHub Client ID is not configured.');
}

let intervalMs = intervalSeconds * 1000;

while (!isCancelled()) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));

if (isCancelled()) break;

// Append a timestamp to completely bypass proxy CDN caching!
const targetUrl = `https://github.com/login/oauth/access_token?_t=${Date.now()}`;
const url = `${CORS_PROXY}${encodeURIComponent(targetUrl)}`;

const response = await fetch(url, {
const response = await fetch(OAUTH_PROXY_URL, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
},
body: JSON.stringify({
client_id: CLIENT_ID,
Expand All @@ -82,16 +91,14 @@ export async function pollForToken(
const data = await response.json();

if (data.access_token) {
return data.access_token; // Success!
return data.access_token;
}

if (data.error) {
if (data.error === 'authorization_pending') {
// Keep waiting
continue;
}
if (data.error === 'slow_down') {
// Add extra time to interval if requested by GitHub
intervalMs += 5000;
continue;
}
Expand All @@ -101,10 +108,10 @@ export async function pollForToken(
if (data.error === 'access_denied') {
throw new Error('Authorization was denied by the user.');
}

throw new Error(data.error_description || data.error);
}
}

throw new Error('Cancelled');
}
Loading
Loading