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
41 changes: 41 additions & 0 deletions apps/worker/src/account-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { cookie, hasValidCsrfToken, json } from "./http";
import { getSessionLookup } from "./request-session";
import { deleteSession, deleteSessionsForUser } from "./session-store";
import { ensureSessionUserHash, settingsKeyFromHash } from "./settings";
import type { Env } from "./types";

export async function deleteAccount(request: Request, env: Env) {
const lookup = await getSessionLookup(request, env);
if (!lookup.session) {
return json(request, env, { error: "Not authenticated" }, { status: 401 });
}
if (!hasValidCsrfToken(request, lookup.session)) {
return json(request, env, { error: "Invalid CSRF token" }, { status: 403 });
}

const githubUserHash = await ensureSessionUserHash(lookup.session, env);
if (githubUserHash && env.SETTINGS_KV) {
await env.SETTINGS_KV.delete(settingsKeyFromHash(githubUserHash));
}
if (githubUserHash) {
await deleteSessionsForUser(githubUserHash, env);
}
if (lookup.sessionId) {
await deleteSession(lookup.sessionId, env, lookup.session);
}

return json(
request,
env,
{
ok: true,
deleted_server_state: true,
stores_repository_data: false,
},
{
headers: {
"Set-Cookie": cookie(request, "forage_session", "", { maxAge: 0 }),
},
},
);
}
138 changes: 138 additions & 0 deletions apps/worker/src/auth-coordinator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { OAuthStateRecord, RateLimitRecord, StoredSessionRecord } from "./types";

export class AuthCoordinator {
constructor(private readonly state: DurableObjectState) {}

async fetch(request: Request) {
const url = new URL(request.url);

if (url.pathname === "/oauth-state") {
return await this.handleOAuthState(request);
}

if (url.pathname === "/session") {
return await this.handleSession(request);
}

if (url.pathname === "/session-index") {
return await this.handleSessionIndex(request);
}

if (url.pathname === "/session-index/all" && request.method === "DELETE") {
await this.state.storage.delete("sessionIds");
return new Response(null, { status: 204 });
}

if (url.pathname === "/rate-limit" && request.method === "POST") {
return await this.handleRateLimit(request);
}

return new Response("Not found", { status: 404 });
}

private async handleOAuthState(request: Request) {
if (request.method === "PUT") {
await this.state.storage.put("record", await request.json<OAuthStateRecord>());
return new Response(null, { status: 204 });
}

if (request.method === "POST") {
const record = await this.state.storage.get<OAuthStateRecord>("record");
await this.state.storage.delete("record");
if (!record) return new Response(null, { status: 404 });
return jsonFromObject(record);
}

return new Response("Method not allowed", { status: 405 });
}

private async handleSession(request: Request) {
if (request.method === "PUT") {
await this.state.storage.put("record", await request.json<StoredSessionRecord>());
return new Response(null, { status: 204 });
}

if (request.method === "GET") {
const stored = await this.state.storage.get<StoredSessionRecord>("record");
if (!stored) return new Response(null, { status: 404 });
if (Date.now() >= stored.expiresAt) {
await this.state.storage.delete("record");
return new Response(null, { status: 404 });
}
return jsonFromObject(stored);
}

if (request.method === "DELETE") {
await this.state.storage.delete("record");
return new Response(null, { status: 204 });
}

return new Response("Method not allowed", { status: 405 });
}

private async handleSessionIndex(request: Request) {
if (request.method === "GET") {
const sessionIds = (await this.state.storage.get<string[]>("sessionIds")) ?? [];
return jsonFromObject({ sessionIds });
}

const payload = (await request.json().catch(() => ({}))) as { sessionId?: string };
if (!payload.sessionId) return new Response("Invalid session index payload", { status: 400 });

const sessionIds = (await this.state.storage.get<string[]>("sessionIds")) ?? [];
if (request.method === "PUT") {
await this.state.storage.put("sessionIds", [...new Set([...sessionIds, payload.sessionId])]);
return new Response(null, { status: 204 });
}

if (request.method === "DELETE") {
await this.state.storage.put(
"sessionIds",
sessionIds.filter((sessionId) => sessionId !== payload.sessionId),
);
return new Response(null, { status: 204 });
}

return new Response("Method not allowed", { status: 405 });
}

private async handleRateLimit(request: Request) {
const { limit, windowSeconds } = await request.json<{
limit?: number;
windowSeconds?: number;
}>();
if (!limit || !windowSeconds) {
return jsonFromObject({ allowed: false, retryAfterSeconds: 60 }, { status: 400 });
}

const now = Date.now();
const current = await this.state.storage.get<RateLimitRecord>("record");
if (!current || now >= current.resetAt) {
await this.state.storage.put("record", {
count: 1,
resetAt: now + windowSeconds * 1000,
} satisfies RateLimitRecord);
return jsonFromObject({ allowed: true, retryAfterSeconds: 0 });
}

const next = {
...current,
count: current.count + 1,
};
await this.state.storage.put("record", next);
return jsonFromObject({
allowed: next.count <= limit,
retryAfterSeconds: Math.max(1, Math.ceil((next.resetAt - now) / 1000)),
});
}
}

function jsonFromObject(payload: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(payload), {
...init,
headers: {
"Content-Type": "application/json; charset=utf-8",
...init.headers,
},
});
}
4 changes: 4 additions & 0 deletions apps/worker/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const defaultApiVersion = "2022-11-28";
export const defaultWebOrigin = "http://127.0.0.1:4321";
export const oauthStateTtlSeconds = 10 * 60;
export const sessionTtlSeconds = 8 * 60 * 60;
35 changes: 35 additions & 0 deletions apps/worker/src/coordinator-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Env } from "./types";

export function oauthStateObjectName(state: string) {
return `oauth-state:${state}`;
}

export function sessionObjectName(sessionId: string) {
return `session:${sessionId}`;
}

export function userSessionIndexObjectName(githubUserHash: string) {
return `user-sessions:${githubUserHash}`;
}

export function rateLimitObjectName(bucket: string, key: string) {
return `rate-limit:${bucket}:${key}`;
}

export async function coordinatorFetch(
env: Env,
objectName: string,
path: string,
init: RequestInit = {},
) {
if (!env.AUTH_COORDINATOR) throw new Error("Missing AUTH_COORDINATOR binding");
const id = env.AUTH_COORDINATOR.idFromName(objectName);
const stub = env.AUTH_COORDINATOR.get(id);
return await stub.fetch(`https://forage-auth.local${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
}
95 changes: 95 additions & 0 deletions apps/worker/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { EncryptedSessionRecord, Env, Session } from "./types";

export function createId() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}

export function base64UrlEncode(bytes: Uint8Array) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

export function base64UrlDecode(value: string) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
}

export async function hashGitHubUserId(userId: number, env: Env) {
const salt = env.SETTINGS_HASH_SALT || env.GITHUB_CLIENT_SECRET || "forage-local-dev";
const data = new TextEncoder().encode(`github-user:${userId}:${salt}`);
const digest = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

export async function operationalHash(value: string, env: Env) {
const salt = env.SETTINGS_HASH_SALT || env.GITHUB_CLIENT_SECRET || "forage-local-dev";
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(`${value}:${salt}`),
);
return base64UrlEncode(new Uint8Array(digest));
}

export function createPkceVerifier() {
return createId();
}

export async function createPkceChallenge(verifier: string) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return base64UrlEncode(new Uint8Array(digest));
}

export async function encryptSession(session: Session, env: Env): Promise<EncryptedSessionRecord> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(session));
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
await sessionEncryptionKey(env),
encoded,
);

return {
version: 1,
algorithm: "AES-GCM",
iv: base64UrlEncode(iv),
ciphertext: base64UrlEncode(new Uint8Array(ciphertext)),
};
}

export async function decryptSession(record: EncryptedSessionRecord, env: Env): Promise<Session> {
if (record.version !== 1 || record.algorithm !== "AES-GCM") {
throw new Error("Unsupported session record");
}

const plaintext = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: base64UrlDecode(record.iv),
},
await sessionEncryptionKey(env),
base64UrlDecode(record.ciphertext),
);

return JSON.parse(new TextDecoder().decode(plaintext)) as Session;
}

async function sessionEncryptionKey(env: Env) {
const secret = env.SESSION_ENCRYPTION_KEY || env.GITHUB_CLIENT_SECRET;
if (!secret) {
throw new Error("Missing SESSION_ENCRYPTION_KEY or GITHUB_CLIENT_SECRET");
}

const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
return await crypto.subtle.importKey("raw", digest, { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
}
34 changes: 34 additions & 0 deletions apps/worker/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defaultApiVersion, defaultWebOrigin } from "./constants";
import type { Env } from "./types";

export function isProduction(env: Env) {
return env.ENVIRONMENT === "production";
}

export function redirectUri(request: Request, env: Env) {
if (env.GITHUB_REDIRECT_URI) return env.GITHUB_REDIRECT_URI;
const url = new URL(request.url);
return `${url.origin}/auth/github/callback`;
}

export function githubApiVersion(env: Env) {
return env.GITHUB_API_VERSION ?? defaultApiVersion;
}

export function webOrigin(env: Env) {
return env.WEB_ORIGIN ?? defaultWebOrigin;
}

export function settingsStore(env: Env) {
return env.SETTINGS_KV ? "cloudflare-kv" : "in-memory-dev";
}

export function sessionStore(env: Env) {
if (env.AUTH_COORDINATOR) return "durable-object-encrypted";
return env.SESSION_KV ? "cloudflare-kv-encrypted" : "in-memory-dev";
}

export function oauthStateStore(env: Env) {
if (env.AUTH_COORDINATOR) return "durable-object";
return env.OAUTH_STATE_KV ? "cloudflare-kv" : "in-memory-dev";
}
Loading
Loading