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
3 changes: 2 additions & 1 deletion apps/gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"wrangler": "^4.92.0"
},
"dependencies": {
"@constructor/protocol": "workspace:*",
"jose": "^6.2.3"
}
}
}
46 changes: 36 additions & 10 deletions apps/gateway/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { jwtVerify, SignJWT } from "jose";
import type { GatewayEnv } from "./types";
import { errorResponse, json } from "./index";
import { errorResponse } from "./index";

const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize";
const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
Expand All @@ -15,6 +15,8 @@ interface PkceSession {
appRedirectUri: string;
}

const ALLOWED_APP_REDIRECT = "mobile://auth/callback";

function generateCodeVerifier(): string {
const arr = new Uint8Array(64);
crypto.getRandomValues(arr);
Expand Down Expand Up @@ -42,6 +44,9 @@ export async function handleAuthStart(request: Request, env: GatewayEnv): Promis
if (!appRedirectUri) {
return errorResponse("redirect_uri is required", 400);
}
if (appRedirectUri !== ALLOWED_APP_REDIRECT) {
return errorResponse("redirect_uri is not allowed", 400);
}

const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
Expand Down Expand Up @@ -77,6 +82,7 @@ export async function handleAuthCallback(request: Request, env: GatewayEnv): Pro
return errorResponse("Invalid or expired session", 400);
}
const session = JSON.parse(sessionRaw) as PkceSession;
await env.GATEWAY_KV.delete(`pkce:${state}`);

// Exchange code for GitHub access token
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
Expand Down Expand Up @@ -138,39 +144,53 @@ export async function handleAuthCallback(request: Request, env: GatewayEnv): Pro
}

// Issue app JWT
const sessionId = crypto.randomUUID();
await env.GATEWAY_KV.put(`app-session:${sessionId}`, JSON.stringify({
scmUserId: String(user.id),
scmLogin: user.login,
scmName: user.name || user.login,
scmEmail: email || `${user.id}+${user.login}@users.noreply.github.com`,
scmToken: ghToken,
} satisfies StoredAppSession), {
expirationTtl: JWT_TTL_SECONDS,
});
const jwt = await signAppJwt(env, {
sub: String(user.id),
sessionId,
scmUserId: String(user.id),
scmLogin: user.login,
scmName: user.name || user.login,
scmEmail: email || `${user.id}+${user.login}@users.noreply.github.com`,
scmToken: ghToken,
});

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

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

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

async function signAppJwt(env: GatewayEnv, payload: JwtPayload): Promise<string> {
type StoredAppSession = Omit<JwtPayload, "sub" | "sessionId">;

async function signAppJwt(env: GatewayEnv, payload: Omit<JwtPayload, "scmToken">): Promise<string> {
const key = await importJwk(env.APP_JWT_SIGNING_KEY);
return new SignJWT({
sessionId: payload.sessionId,
scmUserId: payload.scmUserId,
scmLogin: payload.scmLogin,
scmName: payload.scmName,
scmEmail: payload.scmEmail,
scmToken: payload.scmToken,
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(payload.sub)
Expand All @@ -186,13 +206,19 @@ export async function verifyAppJwt(env: GatewayEnv, token: string): Promise<JwtP
algorithms: ["HS256"],
clockTolerance: 60,
});
const sessionId = payload.sessionId as string | undefined;
if (!sessionId) return null;
const sessionRaw = await env.GATEWAY_KV.get(`app-session:${sessionId}`);
if (!sessionRaw) return null;
const session = JSON.parse(sessionRaw) as StoredAppSession;
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,
sessionId,
scmUserId: session.scmUserId,
scmLogin: session.scmLogin,
scmName: session.scmName,
scmEmail: session.scmEmail,
scmToken: session.scmToken,
};
} catch {
return null;
Expand Down
30 changes: 28 additions & 2 deletions apps/gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
* 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";
import { handlePushRegister, pollAndSendPushNotifications } from "./push";

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

Expand All @@ -25,6 +25,10 @@ export default {
const path = url.pathname;

try {
if (request.method === "OPTIONS") {
return handleOptions(request);
}

if (path === "/config") {
return handleConfig(request, env);
}
Expand All @@ -37,6 +41,10 @@ export default {
return handleAuthCallback(request, env);
}

if (path === "/push/register" && request.method === "POST") {
return handlePushRegister(request, env);
}

if (path.startsWith("/sessions")) {
return handleProxy(request, env);
}
Expand All @@ -47,12 +55,30 @@ export default {
return json({ error: "Internal server error" }, 500);
}
},

async scheduled(_controller: ScheduledController, env: GatewayEnv, ctx: ExecutionContext): Promise<void> {
ctx.waitUntil(pollAndSendPushNotifications(env));
},
} satisfies ExportedHandler<GatewayEnv>;

export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,HEAD,POST,PUT,PATCH,OPTIONS",
"Access-Control-Allow-Headers": "Authorization,Content-Type,Accept",
"Access-Control-Max-Age": "86400",
};

export function handleOptions(request: Request): Response {
if (request.headers.get("Origin") && request.headers.get("Access-Control-Request-Method")) {
return new Response(null, { headers: corsHeaders });
}
return new Response(null, { headers: { Allow: "GET, HEAD, POST, PUT, PATCH, OPTIONS" } });
}

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

Expand Down
27 changes: 27 additions & 0 deletions apps/gateway/src/internal-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export 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("");
}

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

export async function internalAuthHeaders(secret: string): Promise<HeadersInit> {
return {
Accept: "application/json",
Authorization: `Bearer ${await generateInternalToken(secret)}`,
};
}
39 changes: 12 additions & 27 deletions apps/gateway/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,8 @@
import type { GatewayEnv } from "./types";
import { errorResponse, json } from "./index";
import { corsHeaders as defaultCorsHeaders, errorResponse } from "./index";
import { verifyAppJwt } from "./auth";

const TOKEN_VALIDITY_MS = 5 * 60 * 1000;

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}`;
}
import { generateInternalToken } from "./internal-auth";
import { recordUserSessions } from "./push";

export async function handleProxy(request: Request, env: GatewayEnv): Promise<Response> {
const authHeader = request.headers.get("Authorization");
Expand Down Expand Up @@ -74,15 +53,21 @@ export async function handleProxy(request: Request, env: GatewayEnv): Promise<Re
});

const response = await fetch(upstream);
if (request.method === "GET" && url.pathname === "/sessions" && response.ok) {
const cloned = response.clone();
await cloned.json().then((body) => recordUserSessions(env, user, body)).catch(() => undefined);
}

// Pass through CORS
const corsHeaders = new Headers(response.headers);
corsHeaders.set("Access-Control-Allow-Origin", "*");
const responseHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(defaultCorsHeaders)) {
responseHeaders.set(key, value);
}

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

Expand Down
Loading
Loading