/** * ripgit auth worker — GitHub OAuth example * * Access model: * Anonymous → read-only (browse, clone, search) * Authenticated → read + open issues/PRs on any repo * Authenticated + owner → write (push, merge, admin) on your own repos * * Routes handled here (everything else forwarded to ripgit): * GET / → auth landing page (HTML or text mode) * GET /login → start GitHub OAuth login, ?next= redirect after * GET /logout → clear session cookie * GET /settings → token management page (HTML or text mode, requires login) * POST /settings/tokens → create access token * POST /settings/tokens/:id/revoke → revoke access token * GET /oauth/authorize → OAuth provider flow (programmatic clients) * GET /oauth/callback → unified GitHub callback * POST /oauth/token → token exchange (OAuthProvider internal) * * /admin/* → agent management API (requires admin scope) * * Setup: * 1. Create a GitHub OAuth App (https://github.com/settings/applications/new) * Callback URL: https://your-worker.workers.dev/oauth/callback * (local dev: http://localhost:8787/oauth/callback) * 2. wrangler kv namespace create OAUTH_KV → fill IDs in wrangler.toml * 3. wrangler secret put GITHUB_CLIENT_SECRET * 4. wrangler secret put SESSION_SECRET (any random 32+ char string) * 5. Set GITHUB_CLIENT_ID in wrangler.toml [vars] */ import { OAuthProvider, type AuthRequest, type ResolveExternalTokenInput, type ResolveExternalTokenResult, } from "@cloudflare/workers-oauth-provider"; import { WorkerEntrypoint } from "cloudflare:workers"; import { exchangeCode, fetchGitHubUser, githubAuthorizeUrl } from "./github"; import type { ActorProps, Env } from "./types"; const ALL_SCOPES = [ "repo:read", "repo:write", "issue:write", "pr:merge", "admin", ] as const; const SESSION_COOKIE = "ripgit_session"; const SESSION_MAX_AGE = 7 * 24 * 3600; // 7 days type PageFormat = "html" | "markdown" | "text"; interface PageFormatSelection { format: PageFormat; varyAccept: boolean; } interface TextAction { method: "GET" | "POST"; path: string; description: string; requires?: string; fields?: string[]; effect?: string; } // --------------------------------------------------------------------------- // AdminHandler — WorkerEntrypoint for /admin/* routes (programmatic API). // ctx.props populated by OAuthProvider after token validation. // --------------------------------------------------------------------------- export class AdminHandler extends WorkerEntrypoint { async fetch(request: Request): Promise { const actor = this.ctx.props as ActorProps; const url = new URL(request.url); if (url.pathname === "/admin/agents") { if (request.method === "POST") return this.createAgent(request, actor); if (request.method === "GET") return this.listAgents(actor); return new Response("Method Not Allowed", { status: 405 }); } return new Response("Not Found", { status: 404 }); } private async createAgent( request: Request, caller: ActorProps, ): Promise { if (!caller.scopes.includes("admin")) { return Response.json({ error: "admin scope required" }, { status: 403 }); } let body: { name?: unknown; scopes?: unknown }; try { body = (await request.json()) as { name?: unknown; scopes?: unknown }; } catch { return Response.json({ error: "invalid JSON body" }, { status: 400 }); } const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : null; if (!name) { return Response.json({ error: "name is required" }, { status: 400 }); } const requested = Array.isArray(body.scopes) ? (body.scopes as unknown[]).filter( (s): s is string => typeof s === "string", ) : [...ALL_SCOPES]; const grantedScopes = requested.filter( (s) => ALL_SCOPES.includes(s as (typeof ALL_SCOPES)[number]) && caller.scopes.includes(s), ); const { token, actor } = await createAgentToken( this.env, name, caller, grantedScopes, ); return Response.json( { token, actorId: actor.actorId, name, scopes: grantedScopes }, { status: 201 }, ); } private async listAgents(caller: ActorProps): Promise { if (!caller.scopes.includes("admin")) { return Response.json({ error: "admin scope required" }, { status: 403 }); } const agents = await listAgentTokens(this.env, caller.actorId); return Response.json({ agents }); } } // --------------------------------------------------------------------------- // OAuthProvider — default export. // --------------------------------------------------------------------------- export default new OAuthProvider({ apiRoute: "/admin/", apiHandler: AdminHandler, defaultHandler: { fetch: mainHandler }, authorizeEndpoint: "/oauth/authorize", tokenEndpoint: "/oauth/token", clientRegistrationEndpoint: "/oauth/register", scopesSupported: [...ALL_SCOPES], accessTokenTTL: 3600, refreshTokenTTL: 30 * 86400, resolveExternalToken: async ({ token, env, }: ResolveExternalTokenInput): Promise => { const raw = await (env as Env).OAUTH_KV.get(`agent:${token}`); if (!raw) return null; return { props: JSON.parse(raw) as ActorProps }; }, }); // --------------------------------------------------------------------------- // mainHandler — resolves actor first, routes, then forwards to ripgit // --------------------------------------------------------------------------- async function mainHandler(request: Request, env: Env): Promise { const url = new URL(request.url); const pageFormat = preferredPageFormat(request); // Resolve identity up front — available to all routes below const actor = await resolveActor(request, env); // ── Auth + settings routes ──────────────────────────────────────────────── if (url.pathname === "/login") return handleLogin(request, env); if (url.pathname === "/logout") return handleLogout(request); if (url.pathname === "/oauth/authorize") return handleAuthorize(request, env); if (url.pathname === "/oauth/callback") return handleCallback(request, env); if (url.pathname === "/settings") { if (!actor) { if (pageFormat.format === "html") return redirect(`/login?next=/settings`); return renderSettingsAuthRequiredPage(pageFormat); } return handleSettings(request, env, actor, undefined, pageFormat); } if (url.pathname === "/settings/tokens" && request.method === "POST") { if (!actor) return redirect(`/login?next=/settings`); return handleCreateToken(request, env, actor); } // /settings/tokens/:agentId/revoke const revokeMatch = url.pathname.match( /^\/settings\/tokens\/([^/]+)\/revoke$/, ); if (revokeMatch && request.method === "POST") { if (!actor) return redirect(`/login?next=/settings`); // decodeURIComponent because url.pathname preserves %3A rather than // normalising it to ':', so "agent%3Auuid" would not match the KV key. return handleRevokeToken(env, actor, decodeURIComponent(revokeMatch[1])); } if (url.pathname === "/" && request.method === "GET") { // Logged-in users go straight to their profile page if (actor && pageFormat.format === "html") { return redirect(`/${actor.actorName}/`); } return renderLandingPage(new URL(request.url).origin, actor, pageFormat); } // ── Everything else → ripgit ────────────────────────────────────────────── return forwardToRipgit(request, actor, env); } function preferredPageFormat(request: Request): PageFormatSelection { const url = new URL(request.url); const format = url.searchParams.get("format")?.trim().toLowerCase(); if (format === "html") return { format: "html", varyAccept: false }; if (format === "md" || format === "markdown") { return { format: "markdown", varyAccept: false }; } if (format === "text" || format === "txt" || format === "plain") { return { format: "text", varyAccept: false }; } const accept = request.headers.get("Accept") ?? ""; if (accept.includes("text/markdown")) { return { format: "markdown", varyAccept: true }; } if (accept.includes("text/plain")) { return { format: "text", varyAccept: true }; } if (accept.includes("text/html")) { return { format: "html", varyAccept: true }; } return { format: "html", varyAccept: false }; } function respondPage( body: string, selection: PageFormatSelection, status = 200, ): Response { const headers = new Headers(); if (selection.format === "html") { headers.set("Content-Type", "text/html; charset=utf-8"); } else { headers.set( "Content-Type", selection.format === "markdown" ? "text/markdown; charset=utf-8" : "text/plain; charset=utf-8", ); headers.set("Cache-Control", "no-cache"); } if (selection.varyAccept) { headers.set("Vary", "Accept"); } return new Response(body, { status, headers }); } function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">"); } function textNavigationHint(selection: PageFormatSelection): string { const accept = selection.format === "markdown" ? "text/markdown" : "text/plain"; const format = selection.format === "markdown" ? "md" : "text"; return `GET paths below omit \`?format\`. Keep \`Accept: ${accept}\` to stay in text mode, or append \`?format=${format}\` when following a path without headers.`; } function renderTextActions(actions: TextAction[]): string { if (actions.length === 0) return ""; const lines = ["", "## Actions"]; for (const action of actions) { let line = `- ${action.method} \`${action.path}\` - ${action.description}`; if (action.fields?.length) { line += `; fields: ${action.fields.join(", ")}`; } if (action.requires) { line += `; requires ${action.requires}`; } if (action.effect) { line += `; ${action.effect}`; } lines.push(line); } return `${lines.join("\n")}\n`; } function renderTextHints(hints: string[]): string { if (hints.length === 0) return ""; return `\n## Hints\n${hints.map((hint) => `- ${hint}`).join("\n")}\n`; } function authFooterHtml(): string { return `ripgit — open source by deathbyknowledge`; } function renderAuthPageHtml(options: { title: string; topbarRight: string; content: string; mainClass?: string; footer?: string; }): string { return ` ${escapeHtml(options.title)} — ripgit
${options.content}
${options.footer ? `
${options.footer}
` : ""} `; } // --------------------------------------------------------------------------- // Settings — browser UI for token management // --------------------------------------------------------------------------- async function handleSettings( request: Request, env: Env, actor: ActorProps, newToken?: string, pageFormat = preferredPageFormat(request), ): Promise { const tokens = await listAgentTokens(env, actor.actorId); const origin = new URL(request.url).origin; if (pageFormat.format === "html") { return respondPage( renderSettingsPageHtml(actor.actorName, tokens, origin, newToken), pageFormat, ); } return respondPage( renderSettingsPageText(actor.actorName, tokens, origin, newToken, pageFormat), pageFormat, ); } async function handleCreateToken( request: Request, env: Env, actor: ActorProps, ): Promise { const form = await request.formData(); const name = ((form.get("name") as string) ?? "").trim(); if (!name) return redirect("/settings"); const { token } = await createAgentToken(env, name, actor, [...ALL_SCOPES]); // Re-render settings page with the new token shown once return handleSettings(request, env, actor, token); } async function handleRevokeToken( env: Env, actor: ActorProps, agentId: string, ): Promise { const indexKey = `agent-index:${actor.actorId}:${agentId}`; const token = await env.OAUTH_KV.get(indexKey); if (token) { await Promise.all([ env.OAUTH_KV.delete(`agent:${token}`), env.OAUTH_KV.delete(indexKey), ]); } return redirect("/settings"); } // --------------------------------------------------------------------------- // Settings pages // --------------------------------------------------------------------------- function renderSettingsAuthRequiredPage( pageFormat: PageFormatSelection, ): Response { const body = `# ripgit auth settings Authentication required. Settings path: \`/settings\` ${renderTextActions([ { method: "GET", path: "/login?next=/settings", description: "start GitHub OAuth sign-in in a browser", }, { method: "GET", path: "/", description: "open the auth worker landing page", }, ])}${renderTextHints([ textNavigationHint(pageFormat), "Browser login creates a session cookie; long-lived agent tokens are created after signing in at `/settings`.", "If you already have a token, send it as `Authorization: Bearer TOKEN` or as the password in basic auth to avoid the browser login redirect.", ])}`; return respondPage(body, pageFormat, 401); } function renderSettingsPageHtml( actorName: string, tokens: { agentId: string; name: string }[], origin: string, newToken?: string, ): string { const host = origin.replace(/^https?:\/\//, ""); const newTokenBanner = newToken ? `` : ""; const tokenRows = tokens.length > 0 ? ` ${tokens .map( (t) => ``, ) .join("")}
Name
${escapeHtml(t.name)}
` : `

No tokens yet.

`; return renderAuthPageHtml({ title: "Settings", topbarRight: `Profile·${escapeHtml(actorName)}·Sign out`, mainClass: "site-shell", footer: authFooterHtml(), content: ` ${newTokenBanner}

Access Tokens

Create long-lived tokens for git remotes, curl, and agents that need to browse or push through the auth worker.

Create token

Generated tokens are shown exactly once in the response that creates them.

Push a new repo

Repos are created on first push. Pick any name:

cd my-project
git init
git add .
git commit -m "initial commit"
git remote add origin https://${escapeHtml(actorName)}:TOKEN@${escapeHtml(host)}/${escapeHtml(actorName)}/my-project
git push origin main

Replace TOKEN with the token you generate above. Replace my-project with your repo name.

Use tokens with curl

curl -H "Authorization: Bearer TOKEN" ${escapeHtml(origin)}/settings?format=md

Git can also use the token as the password in a standard HTTPS remote.

Active tokens

${tokenRows}
`, }); } function renderSettingsPageText( actorName: string, tokens: { agentId: string; name: string }[], origin: string, newToken: string | undefined, pageFormat: PageFormatSelection, ): string { const host = origin.replace(/^https?:\/\//, ""); const pushExample = renderIndentedBlock( [ "cd my-project", "git init", "git add .", 'git commit -m "initial commit"', `git remote add origin https://${actorName}:TOKEN@${host}/${actorName}/my-project`, "git push origin main", ].join("\n"), ); let body = `# ripgit auth settings Signed in as: \`${actorName}\` Profile path: \`/${actorName}/\` Settings path: \`/settings\` Active tokens: \`${tokens.length}\` `; if (newToken) { body += ` ## New Token Copy this now; it will not be shown again. - Value: \`${newToken}\` - Git remote: \`https://${actorName}:${newToken}@${host}/${actorName}/REPO-NAME\` `; } body += ` ## Push a New Repo ${pushExample} `; body += "\n## Active Tokens\n"; if (tokens.length === 0) { body += "No active tokens.\n"; } else { for (const token of tokens) { body += `- \`${token.name}\` - revoke path: \`/settings/tokens/${encodeURIComponent(token.agentId)}/revoke\`\n`; } } const actions: TextAction[] = [ { method: "GET", path: "/settings", description: "reload this token management page", }, { method: "GET", path: `/${actorName}/`, description: "open your ripgit profile and repo index", }, { method: "POST", path: "/settings/tokens", description: "create a new long-lived token", fields: ["`name` - label shown in the settings page"], requires: "authenticated session", effect: "returns the settings page with the new token shown once", }, { method: "GET", path: "/logout?next=/", description: "clear the browser session and return to the landing page", }, ]; for (const token of tokens) { actions.push({ method: "POST", path: `/settings/tokens/${encodeURIComponent(token.agentId)}/revoke`, description: `revoke the token named \`${token.name}\``, requires: "authenticated session", effect: "deletes the token and redirects back to `/settings`", }); } body += renderTextActions(actions); body += renderTextHints([ textNavigationHint(pageFormat), "Created tokens currently carry the full auth-worker scope set and should be stored like passwords.", "Use `Authorization: Bearer TOKEN` for API/page requests, or `https://USER:TOKEN@HOST/USER/REPO` for git remotes.", "Generated tokens are only shown in the response that creates them; revoking a token does not reveal its original value.", ]); return body; } function renderIndentedBlock(text: string): string { return text .split("\n") .map((line) => ` ${line}`) .join("\n"); } // --------------------------------------------------------------------------- // Landing page (shown at / when not logged in) // --------------------------------------------------------------------------- function renderLandingPage( origin: string, actor: ActorProps | null, pageFormat: PageFormatSelection, ): Response { if (pageFormat.format === "html") { return respondPage(renderLandingPageHtml(), pageFormat); } return respondPage(renderLandingPageText(origin, actor, pageFormat), pageFormat); } function renderLandingPageHtml(): string { return renderAuthPageHtml({ title: "ripgit", topbarRight: "", mainClass: "landing-shell", footer: authFooterHtml(), content: `

ripgit

A lightweight self-hosted Git server running on Cloudflare Durable Objects. Fast, searchable, yours.

Git-compatible

Works with any standard git client. Push and clone with the URLs you already know.

Built-in search

Full-text search across all your code and commit history, powered by SQLite FTS5.

Edge-hosted

Runs on Cloudflare Durable Objects. No servers to manage, globally distributed.

`, }); } function renderLandingPageText( origin: string, actor: ActorProps | null, pageFormat: PageFormatSelection, ): string { const host = origin.replace(/^https?:\/\//, ""); if (actor) { let body = `# ripgit auth worker Signed in as: \`${actor.actorName}\` This auth worker fronts the ripgit backend, manages your browser session, and can mint long-lived tokens from \`/settings\`. ## Related Paths (GET paths) - \`/${actor.actorName}/\` - \`/settings\` - \`/logout?next=/\` `; body += renderTextActions([ { method: "GET", path: `/${actor.actorName}/`, description: "open your ripgit profile and repositories", }, { method: "GET", path: "/settings", description: "manage long-lived access tokens", }, { method: "GET", path: "/logout?next=/", description: "clear the browser session and return here", }, ]); body += renderTextHints([ textNavigationHint(pageFormat), `HTML requests to \`/\` redirect signed-in users to \`/${actor.actorName}/\`; text mode stays here so agents can discover the next steps.`, `Tokens created at \`/settings\` work with git remotes like \`https://${actor.actorName}:TOKEN@${host}/${actor.actorName}/REPO\`.`, ]); return body; } let body = `# ripgit auth worker This worker handles GitHub sign-in, browser sessions, and long-lived tokens before forwarding requests to the ripgit backend. Access model: - anonymous - read-only browsing, cloning, and search - authenticated - read plus issues and pull requests across repos - repo owner - push, merge, and admin actions on repos under your username ## Related Paths (GET paths) - \`/\` - \`/login\` - \`/settings\` - \`/oauth/authorize\` `; body += renderTextActions([ { method: "GET", path: "/login", description: "start GitHub OAuth sign-in in a browser", }, { method: "GET", path: "/settings", description: "open token management after sign-in", requires: "authenticated session", }, { method: "GET", path: "/oauth/authorize", description: "start the OAuth provider flow for programmatic clients", }, ]); body += renderTextHints([ textNavigationHint(pageFormat), "After signing in, the HTML landing page redirects to your profile while the text-mode landing page stays here and explains the available paths.", "Long-lived tokens are created from `/settings` and can then be sent as `Authorization: Bearer TOKEN` or used as the password in a standard HTTPS git remote.", ]); return body; } // --------------------------------------------------------------------------- // GitHub OAuth flows // --------------------------------------------------------------------------- async function handleLogin(request: Request, env: Env): Promise { const next = new URL(request.url).searchParams.get("next") ?? "/"; const state = crypto.randomUUID(); await env.OAUTH_KV.put( `state:${state}`, JSON.stringify({ type: "login", next }), { expirationTtl: 600 }, ); const callbackUrl = new URL("/oauth/callback", request.url).toString(); return Response.redirect( githubAuthorizeUrl(env.GITHUB_CLIENT_ID, state, callbackUrl), 302, ); } async function handleAuthorize(request: Request, env: Env): Promise { const oauthReq = await env.OAUTH_PROVIDER.parseAuthRequest(request); const state = crypto.randomUUID(); await env.OAUTH_KV.put( `state:${state}`, JSON.stringify({ type: "oauth", oauthReq }), { expirationTtl: 600 }, ); const callbackUrl = new URL("/oauth/callback", request.url).toString(); return Response.redirect( githubAuthorizeUrl(env.GITHUB_CLIENT_ID, state, callbackUrl), 302, ); } async function handleCallback(request: Request, env: Env): Promise { const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state) { return new Response("Missing code or state", { status: 400 }); } const stateRaw = await env.OAUTH_KV.get(`state:${state}`); if (!stateRaw) { return new Response("Invalid or expired state — please try again", { status: 400, }); } await env.OAUTH_KV.delete(`state:${state}`); type StateData = | { type: "login"; next: string } | { type: "oauth"; oauthReq: AuthRequest }; const stateData = JSON.parse(stateRaw) as StateData; const callbackUrl = new URL("/oauth/callback", request.url).toString(); let githubToken: string; try { githubToken = await exchangeCode(code, env, callbackUrl); } catch (err) { return new Response(`GitHub auth failed: ${(err as Error).message}`, { status: 400, }); } const user = await fetchGitHubUser(githubToken); if (stateData.type === "login") { const actor: ActorProps = { actorId: `github:${user.id}`, actorName: user.login, actorKind: "user", scopes: [...ALL_SCOPES], }; const sessionValue = await createSession(actor, env.SESSION_SECRET); return new Response(null, { status: 302, headers: { Location: stateData.next, "Set-Cookie": `${SESSION_COOKIE}=${encodeURIComponent(sessionValue)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_MAX_AGE}`, }, }); } else { const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({ request: stateData.oauthReq, userId: `github:${user.id}`, metadata: { githubLogin: user.login }, scope: stateData.oauthReq.scope, props: { actorId: `github:${user.id}`, actorName: user.login, actorKind: "user", scopes: stateData.oauthReq.scope, } satisfies ActorProps, }); return Response.redirect(redirectTo, 302); } } function handleLogout(request: Request): Response { const next = new URL(request.url).searchParams.get("next") ?? "/"; return new Response(null, { status: 302, headers: { Location: next, "Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`, }, }); } // --------------------------------------------------------------------------- // KV helpers for agent tokens // --------------------------------------------------------------------------- async function createAgentToken( env: Env, name: string, caller: ActorProps, scopes: string[], ): Promise<{ token: string; actor: ActorProps }> { const token = generateToken(); const agentId = `agent:${crypto.randomUUID()}`; const actor: ActorProps = { actorId: agentId, actorName: name, actorKind: "agent", ownerActorId: caller.actorId, ownerActorName: caller.actorName, // caller's GitHub username — used for repo ownership checks scopes, }; await env.OAUTH_KV.put(`agent:${token}`, JSON.stringify(actor)); await env.OAUTH_KV.put(`agent-index:${caller.actorId}:${agentId}`, token); return { token, actor }; } async function listAgentTokens( env: Env, actorId: string, ): Promise<{ agentId: string; name: string }[]> { const prefix = `agent-index:${actorId}:`; const list = await env.OAUTH_KV.list({ prefix }); const results = await Promise.all( list.keys.map(async (k) => { const agentId = k.name.slice(prefix.length); const token = await env.OAUTH_KV.get(k.name); if (!token) return null; const raw = await env.OAUTH_KV.get(`agent:${token}`); if (!raw) return null; const a = JSON.parse(raw) as ActorProps; return { agentId, name: a.actorName }; }), ); return results.filter((r): r is { agentId: string; name: string } => r !== null); } // --------------------------------------------------------------------------- // Session cookie helpers — HMAC-SHA256 signed, stateless // --------------------------------------------------------------------------- async function createSession( actor: ActorProps, secret: string, ): Promise { const payload = btoa(JSON.stringify(actor)); const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign( "HMAC", key, new TextEncoder().encode(payload), ); const sigHex = Array.from(new Uint8Array(sig)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); return `${payload}.${sigHex}`; } async function verifySession( value: string, secret: string, ): Promise { const dot = value.lastIndexOf("."); if (dot < 0) return null; const payload = value.slice(0, dot); const sigHex = value.slice(dot + 1); let sigBytes: Uint8Array; try { const pairs = sigHex.match(/.{2}/g) ?? []; sigBytes = Uint8Array.from(pairs.map((h) => parseInt(h, 16))); } catch { return null; } const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"], ); const valid = await crypto.subtle.verify( "HMAC", key, sigBytes, new TextEncoder().encode(payload), ); if (!valid) return null; try { return JSON.parse(atob(payload)) as ActorProps; } catch { return null; } } function getSessionCookie(request: Request): string | null { const cookies = request.headers.get("Cookie") ?? ""; const match = cookies.match(/(?:^|;\s*)ripgit_session=([^;]+)/); return match ? decodeURIComponent(match[1]) : null; } // --------------------------------------------------------------------------- // Identity resolution — returns null for anonymous, never blocks // --------------------------------------------------------------------------- async function resolveActor( request: Request, env: Env, ): Promise { const token = extractToken(request); if (token) { const agentRaw = await env.OAUTH_KV.get(`agent:${token}`); if (agentRaw) return JSON.parse(agentRaw) as ActorProps; const tokenData = await env.OAUTH_PROVIDER.unwrapToken(token); if (tokenData && tokenData.expiresAt > Math.floor(Date.now() / 1000)) { return tokenData.grant.props; } } const cookieValue = getSessionCookie(request); if (cookieValue) { return verifySession(cookieValue, env.SESSION_SECRET); } return null; } // --------------------------------------------------------------------------- // Forward to ripgit // --------------------------------------------------------------------------- function forwardToRipgit( request: Request, actor: ActorProps | null, env: Env, ): Promise { const headers = new Headers(request.headers); if (actor) { // For ownership checks in ripgit, what matters is the GitHub username of the // person who owns the repos. For agents, that's ownerActorName, not actorName // (actorName is the token's display name, e.g. "laptop"). const ownerName = actor.actorKind === "agent" && actor.ownerActorName ? actor.ownerActorName : actor.actorName; headers.set("X-Ripgit-Actor-Id", actor.actorId); headers.set("X-Ripgit-Actor-Name", ownerName); // GitHub username for ownership headers.set("X-Ripgit-Actor-Display-Name", actor.actorName); // token name for audit/display headers.set("X-Ripgit-Actor-Kind", actor.actorKind); headers.set("X-Ripgit-Actor-Scopes", actor.scopes.join(",")); if (actor.ownerActorId) { headers.set("X-Ripgit-Actor-Owner", actor.ownerActorId); } } headers.delete("Authorization"); headers.delete("Cookie"); return env.RIPGIT.fetch(new Request(request, { headers })); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function extractToken(request: Request): string | null { const auth = request.headers.get("Authorization") ?? ""; if (auth.startsWith("Bearer ")) { const t = auth.slice(7).trim(); return t || null; } if (auth.startsWith("Basic ")) { try { const decoded = atob(auth.slice(6)); const colon = decoded.indexOf(":"); if (colon >= 0) { const t = decoded.slice(colon + 1); return t || null; } } catch { /* malformed base64 */ } } return null; } /** * Redirect to a relative or absolute URL. * Response.redirect() only accepts absolute URLs in Cloudflare Workers, * so use this helper for any relative paths. */ function redirect(location: string, status = 302): Response { return new Response(null, { status, headers: { Location: location } }); } function generateToken(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); }