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
205 changes: 205 additions & 0 deletions apps/gateway/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { jwtVerify, SignJWT } from "jose";
import type { GatewayEnv } from "./types";
import { errorResponse, json } from "./index";

const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize";
const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
const GITHUB_USER_URL = "https://api.github.com/user";
const GITHUB_EMAILS_URL = "https://api.github.com/user/emails";

const PKCE_TTL_SECONDS = 600; // 10 minutes
const JWT_TTL_SECONDS = 24 * 60 * 60; // 24 hours

interface PkceSession {
verifier: string;
appRedirectUri: string;
}

function generateCodeVerifier(): string {
const arr = new Uint8Array(64);
crypto.getRandomValues(arr);
return Array.from(arr)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest("SHA-256", data);
const bytes = new Uint8Array(hash);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

export async function handleAuthStart(request: Request, env: GatewayEnv): Promise<Response> {
const url = new URL(request.url);
const appRedirectUri = url.searchParams.get("redirect_uri");
const state = url.searchParams.get("state") || generateCodeVerifier().slice(0, 32);
Comment on lines +37 to +40

if (!appRedirectUri) {
return errorResponse("redirect_uri is required", 400);
}

const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);

const session: PkceSession = { verifier, appRedirectUri };
await env.GATEWAY_KV.put(`pkce:${state}`, JSON.stringify(session), {
expirationTtl: PKCE_TTL_SECONDS,
});

const authUrl = new URL(GITHUB_AUTH_URL);
authUrl.searchParams.set("client_id", env.GITHUB_OAUTH_CLIENT_ID);
authUrl.searchParams.set("redirect_uri", `${url.origin}/auth/callback`);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "repo user");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");

return Response.redirect(authUrl.toString(), 302);
}

export async function handleAuthCallback(request: Request, env: GatewayEnv): Promise<Response> {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");

if (!code || !state) {
return errorResponse("Missing code or state", 400);
}

const sessionRaw = await env.GATEWAY_KV.get(`pkce:${state}`);
if (!sessionRaw) {
return errorResponse("Invalid or expired session", 400);
}
const session = JSON.parse(sessionRaw) as PkceSession;

// Exchange code for GitHub access token
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: env.GITHUB_OAUTH_CLIENT_ID,
client_secret: env.GITHUB_OAUTH_CLIENT_SECRET,
code,
redirect_uri: `${url.origin}/auth/callback`,
code_verifier: session.verifier,
}),
});

if (!tokenRes.ok) {
return errorResponse("Failed to exchange GitHub code", 502);
}

const tokenData = (await tokenRes.json()) as {
access_token?: string;
error?: string;
};

if (tokenData.error || !tokenData.access_token) {
return errorResponse(tokenData.error || "GitHub auth failed", 502);
}

const ghToken = tokenData.access_token;

// Fetch GitHub user profile
const [userRes, emailsRes] = await Promise.all([
fetch(GITHUB_USER_URL, {
headers: { Authorization: `Bearer ${ghToken}`, Accept: "application/vnd.github+json" },
}),
fetch(GITHUB_EMAILS_URL, {
headers: { Authorization: `Bearer ${ghToken}`, Accept: "application/vnd.github+json" },
}),
]);

if (!userRes.ok) {
return errorResponse("Failed to fetch GitHub user", 502);
}

const user = (await userRes.json()) as {
id: number;
login: string;
name?: string | null;
email?: string | null;
};

let email = user.email;
if (!email && emailsRes.ok) {
const emails = (await emailsRes.json()) as Array<{ email: string; primary: boolean; verified: boolean }>;
const primary = emails.find((e) => e.primary && e.verified);
if (primary) email = primary.email;
}

// Issue app JWT
const jwt = await signAppJwt(env, {
sub: String(user.id),
scmUserId: String(user.id),
scmLogin: user.login,
scmName: user.name || user.login,
scmEmail: email || `${user.id}+${user.login}@users.noreply.github.com`,
scmToken: ghToken,
});
Comment on lines +140 to +148

// Redirect back to app
const redirect = new URL(session.appRedirectUri);
redirect.searchParams.set("token", jwt);

return Response.redirect(redirect.toString(), 302);
}

interface JwtPayload {
sub: string;
scmUserId: string;
scmLogin: string;
scmName: string;
scmEmail: string;
scmToken: string;
}

async function signAppJwt(env: GatewayEnv, payload: JwtPayload): Promise<string> {
const key = await importJwk(env.APP_JWT_SIGNING_KEY);
return new SignJWT({
scmUserId: payload.scmUserId,
scmLogin: payload.scmLogin,
scmName: payload.scmName,
scmEmail: payload.scmEmail,
scmToken: payload.scmToken,
})
Comment on lines +168 to +174
.setProtectedHeader({ alg: "HS256" })
.setSubject(payload.sub)
.setIssuedAt()
.setExpirationTime(`${JWT_TTL_SECONDS}s`)
.sign(key);
}

export async function verifyAppJwt(env: GatewayEnv, token: string): Promise<JwtPayload | null> {
try {
const key = await importJwk(env.APP_JWT_SIGNING_KEY);
const { payload } = await jwtVerify(token, key, {
algorithms: ["HS256"],
clockTolerance: 60,
});
return {
sub: payload.sub as string,
scmUserId: payload.scmUserId as string,
scmLogin: payload.scmLogin as string,
scmName: payload.scmName as string,
scmEmail: payload.scmEmail as string,
scmToken: payload.scmToken as string,
};
} catch {
return null;
}
}

async function importJwk(secret: string): Promise<CryptoKey> {
const encoder = new TextEncoder();
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
}
10 changes: 10 additions & 0 deletions apps/gateway/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { GatewayEnv } from "./types";
import { json } from "./index";

export function handleConfig(_request: Request, env: GatewayEnv): Response {
return json({
controlPlaneUrl: env.CONTROL_PLANE_URL,
wsUrl: env.WS_URL,
githubOAuthClientId: env.GITHUB_OAUTH_CLIENT_ID,
});
}
65 changes: 54 additions & 11 deletions apps/gateway/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,61 @@
/**
* Welcome to Cloudflare Workers! This is your first worker.
* Constructor Mobile Gateway
*
* - Run `npm run dev` in your terminal to start a development server
* - Open a browser tab at http://localhost:8787/ to see your worker in action
* - Run `npm run deploy` to publish your worker
* Compatibility layer between the mobile app and the background-agents control
* plane. No changes are made to background-agents — all transforms happen here.
*
* Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the
* `Env` object can be regenerated with `npm run cf-typegen`.
*
* Learn more at https://developers.cloudflare.com/workers/
* Endpoints:
* GET /config → public config (controlPlaneUrl, wsUrl, githubOAuthClientId)
* GET /auth/start → begins GitHub OAuth PKCE flow
* GET /auth/callback → GitHub OAuth callback, issues app JWT
* GET|POST /sessions/... → HMAC-signed proxy to control plane with user injection
*/

import { jwtVerify, SignJWT } from "jose";
import type { GatewayEnv } from "./types";
import { handleConfig } from "./config";
import { handleAuthStart, handleAuthCallback } from "./auth";
import { handleProxy } from "./proxy";

export type { GatewayEnv } from "./types";

export default {
async fetch(request, env, ctx): Promise<Response> {
return new Response("Hello World!");
async fetch(request: Request, env: GatewayEnv, _ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;

try {
if (path === "/config") {
return handleConfig(request, env);
}

if (path === "/auth/start") {
return handleAuthStart(request, env);
}

if (path === "/auth/callback") {
return handleAuthCallback(request, env);
}

if (path.startsWith("/sessions")) {
return handleProxy(request, env);
Comment on lines +32 to +41
}

return json({ error: "Not found" }, 404);
} catch (e) {
console.error("Gateway error:", e);
return json({ error: "Internal server error" }, 500);
}
},
} satisfies ExportedHandler<Env>;
} satisfies ExportedHandler<GatewayEnv>;

export function json(body: unknown, status = 200): Response {
return Response.json(body, {
status,
headers: { "Access-Control-Allow-Origin": "*" },
});
}

export function errorResponse(message: string, status = 400): Response {
return json({ error: message }, status);
}
109 changes: 109 additions & 0 deletions apps/gateway/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { GatewayEnv } from "./types";
import { errorResponse, json } from "./index";
import { verifyAppJwt } from "./auth";

const TOKEN_VALIDITY_MS = 5 * 60 * 1000;

Comment on lines +5 to +6
async function computeHmacHex(data: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

async function generateInternalToken(secret: string): Promise<string> {
const timestamp = Date.now().toString();
const signatureHex = await computeHmacHex(timestamp, secret);
return `${timestamp}.${signatureHex}`;
}

export async function handleProxy(request: Request, env: GatewayEnv): Promise<Response> {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return errorResponse("Unauthorized", 401);
}

const appToken = authHeader.slice(7);
const user = await verifyAppJwt(env, appToken);
if (!user) {
return errorResponse("Invalid or expired token", 401);
}

// Build the upstream URL
const url = new URL(request.url);
const upstreamPath = url.pathname + url.search;
const upstreamUrl = `${env.CONTROL_PLANE_URL.replace(/\/$/, "")}${upstreamPath}`;

// Copy headers and inject HMAC auth
const headers = new Headers(request.headers);
headers.delete("Authorization"); // remove app JWT

const internalToken = await generateInternalToken(env.INTERNAL_CALLBACK_SECRET);
headers.set("Authorization", `Bearer ${internalToken}`);
headers.set("x-request-id", crypto.randomUUID().slice(0, 8));
headers.set("x-trace-id", crypto.randomUUID());

// Build upstream request body
let body: BodyInit | null = null;
if (["POST", "PUT", "PATCH"].includes(request.method)) {
const contentType = headers.get("Content-Type") || "";
if (contentType.includes("application/json")) {
const originalBody = await request.json();
const enrichedBody = enrichBody(originalBody, user);
body = JSON.stringify(enrichedBody);
Comment on lines +59 to +61
headers.set("Content-Type", "application/json");
} else if (contentType.includes("multipart/form-data")) {
body = request.body;
} else {
body = await request.text();
}
}

const upstream = new Request(upstreamUrl, {
method: request.method,
headers,
body,
});

const response = await fetch(upstream);

// Pass through CORS
const corsHeaders = new Headers(response.headers);
corsHeaders.set("Access-Control-Allow-Origin", "*");

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: corsHeaders,
});
}

function enrichBody(body: unknown, user: {
sub: string;
scmUserId: string;
scmLogin: string;
scmName: string;
scmEmail: string;
scmToken: string;
}): unknown {
if (body && typeof body === "object") {
return {
...body,
userId: user.sub,
scmUserId: user.scmUserId,
scmLogin: user.scmLogin,
scmName: user.scmName,
scmEmail: user.scmEmail,
scmToken: user.scmToken,
};
}
return body;
}
Loading