Skip to content

Commit a10069b

Browse files
zachdunnclaude
andauthored
feat: Better Auth Phase 2 — admin plugin, session middleware, login UI (#94)
* feat(auth): admin plugin + internal promote-admin API Mounts better-auth's admin plugin (adminRoles: ["admin"]), adds the session.impersonated_by column the plugin needs, and exposes a service-binding-only /internal/promote-admin endpoint guarded by isInternalRequest (defense-in-depth: explicit header + absence of cf-connecting-ip). Includes the first-admin bootstrap SQL script and a short README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(api): AUTH service binding + sessionAuth middleware + promote fallback Adds the uploads-auth service binding, a sessionAuth middleware that forwards Cookie/Authorization to the auth worker's get-session over the binding (with requireSessionUser/requireAdminUser guards), and the ADMIN_TOKEN-gated POST /admin/users/promote fallback that proxies to the auth worker's internal promote-admin endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): login page, admin landing, session indicator (fetch-based auth client) Adds apps/web/src/lib/auth-client.ts (plain fetch wrappers against the auth worker's REST endpoints, not the better-auth client bundle -- this app has no client-side npm deps today and a strict CSP; see the module's doc comment for the full rationale), a /login page (GitHub + magic link), a client-gated /admin landing page (stub nav, real boundary stays server-side via requireAdminUser), and a signed-in indicator + sign-out on the console page. UPLOADS_AUTH_ORIGIN added to apps/web/wrangler.jsonc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger workers build (uploads-auth now deployed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d6d8c11 commit a10069b

20 files changed

Lines changed: 942 additions & 5 deletions
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Hono } from "hono";
2+
import { describe, expect, it } from "vitest";
3+
import { respondError } from "../error-response";
4+
import { admin } from "./admin";
5+
6+
const ADMIN_TOKEN = "test-admin-token";
7+
8+
// `crypto.subtle.timingSafeEqual` is a Workers-runtime extension to Web
9+
// Crypto (used by adminAuth, see ../admin.ts) that plain Node's `crypto`
10+
// doesn't implement, and this repo has no vitest workerd pool configured.
11+
// Polyfill a (non-constant-time, test-only) equivalent so this file can
12+
// exercise the real adminAuth middleware end to end rather than bypassing it.
13+
if (typeof crypto.subtle.timingSafeEqual !== "function") {
14+
(
15+
crypto.subtle as unknown as { timingSafeEqual: (a: Uint8Array, b: Uint8Array) => boolean }
16+
).timingSafeEqual = (a: Uint8Array, b: Uint8Array) =>
17+
a.length === b.length && a.every((byte, i) => byte === b[i]);
18+
}
19+
20+
/** Stub matching the Fetcher interface's `.fetch()` shape used by env.AUTH. */
21+
function stubAuth(handler: (req: Request) => Response | Promise<Response>): Pick<Fetcher, "fetch"> {
22+
return {
23+
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
24+
const req = input instanceof Request ? input : new Request(input, init);
25+
return handler(req);
26+
}) as Fetcher["fetch"],
27+
};
28+
}
29+
30+
function appWith(_auth: Pick<Fetcher, "fetch">) {
31+
return new Hono<{ Bindings: Env }>()
32+
.route("/admin", admin)
33+
.onError((err, c) => respondError(c, err));
34+
}
35+
36+
function env(auth: Pick<Fetcher, "fetch">) {
37+
return { ADMIN_TOKEN, AUTH: auth } as unknown as Env;
38+
}
39+
40+
function promoteRequest(email: unknown) {
41+
return new Request("https://api.uploads.sh/admin/users/promote", {
42+
method: "POST",
43+
headers: { authorization: `Bearer ${ADMIN_TOKEN}`, "content-type": "application/json" },
44+
body: JSON.stringify({ email }),
45+
});
46+
}
47+
48+
describe("POST /admin/users/promote", () => {
49+
it("proxies to the auth worker and returns the promoted user", async () => {
50+
const auth = stubAuth((req) => {
51+
expect(req.headers.get("x-uploads-internal")).toBe("1");
52+
return new Response(
53+
JSON.stringify({ ok: true, user: { id: "u1", email: "a@b.com", role: "admin" } }),
54+
{ status: 200 },
55+
);
56+
});
57+
const res = await appWith(auth).request(promoteRequest("a@b.com"), {}, env(auth));
58+
expect(res.status).toBe(200);
59+
expect(await res.json()).toEqual({
60+
ok: true,
61+
user: { id: "u1", email: "a@b.com", role: "admin" },
62+
});
63+
});
64+
65+
it("surfaces the auth worker's 404 (no such user) as a 404", async () => {
66+
const auth = stubAuth(
67+
() =>
68+
new Response(
69+
JSON.stringify({ error: { code: "user_not_found", message: "no such user" } }),
70+
{
71+
status: 404,
72+
},
73+
),
74+
);
75+
const res = await appWith(auth).request(promoteRequest("nobody@b.com"), {}, env(auth));
76+
expect(res.status).toBe(404);
77+
});
78+
79+
it("rejects an invalid email without calling the auth worker", async () => {
80+
let called = false;
81+
const auth = stubAuth(() => {
82+
called = true;
83+
return new Response("{}", { status: 200 });
84+
});
85+
const res = await appWith(auth).request(promoteRequest("not-an-email"), {}, env(auth));
86+
expect(res.status).toBe(400);
87+
expect(called).toBe(false);
88+
});
89+
90+
it("401s without a valid admin token", async () => {
91+
const auth = stubAuth(() => new Response("{}", { status: 200 }));
92+
const req = new Request("https://api.uploads.sh/admin/users/promote", {
93+
method: "POST",
94+
headers: { "content-type": "application/json" },
95+
body: JSON.stringify({ email: "a@b.com" }),
96+
});
97+
const res = await appWith(auth).request(req, {}, env(auth));
98+
expect(res.status).toBe(401);
99+
});
100+
});

apps/api/src/routes/admin.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,43 @@ function requireLabel(label: string | undefined | null): asserts label is string
170170
}
171171
}
172172

173+
const EMAIL_VALID_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
174+
173175
export const admin = new Hono<{ Bindings: Env }>()
174176
.use("/*", adminAuth)
175177

178+
// D9 fallback: ADMIN_TOKEN-gated first-admin/second-admin promotion,
179+
// proxying to the auth worker's internal-only promote-admin endpoint over
180+
// the AUTH service binding. Stays on adminAuth (not sessionAuth) — this is
181+
// explicitly the ops/CI fallback path, not part of the session-auth admin
182+
// UI surface (that's requireAdminUser, added alongside but not wired to any
183+
// route yet — see src/session-auth.ts).
184+
.post("/users/promote", async (c) => {
185+
const body = await c.req.json<{ email?: unknown }>().catch(() => ({}) as { email?: unknown });
186+
const email = typeof body.email === "string" ? body.email.trim() : "";
187+
if (!email || !EMAIL_VALID_RE.test(email)) {
188+
throw new ValidationError("invalid email address", { code: "invalid_email" });
189+
}
190+
191+
// x-uploads-internal marks this as a service-binding call (see
192+
// apps/auth/src/internal.ts); cf-connecting-ip is never set on a binding
193+
// fetch(), so there is nothing to strip here.
194+
const response = await c.env.AUTH.fetch("https://auth.internal/internal/promote-admin", {
195+
method: "POST",
196+
headers: { "content-type": "application/json", "x-uploads-internal": "1" },
197+
body: JSON.stringify({ email }),
198+
});
199+
const payload = await response.json().catch(() => null);
200+
201+
if (response.status === 404) {
202+
throw new NotFoundError("no user with that email", { code: "user_not_found" });
203+
}
204+
if (!response.ok) {
205+
throw new ValidationError("promote-admin failed", { details: payload });
206+
}
207+
return c.json(payload as object, 200);
208+
})
209+
176210
// Mint a scoped bearer token for an existing workspace (defaults to "default").
177211
// New credentials live in D1; legacy KV credentials remain readable/revocable.
178212
.post("/tokens", async (c) => {

apps/api/src/session-auth.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Hono } from "hono";
2+
import { describe, expect, it } from "vitest";
3+
import { respondError } from "./error-response";
4+
import {
5+
requireAdminUser,
6+
requireSessionUser,
7+
sessionAuth,
8+
type SessionVars,
9+
} from "./session-auth";
10+
11+
/** Stub matching the Fetcher interface's `.fetch()` shape used by env.AUTH. */
12+
function stubAuth(handler: (req: Request) => Response | Promise<Response>): Pick<Fetcher, "fetch"> {
13+
return {
14+
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
15+
const req = input instanceof Request ? input : new Request(input, init);
16+
return handler(req);
17+
}) as Fetcher["fetch"],
18+
};
19+
}
20+
21+
function appWith(_auth: Pick<Fetcher, "fetch">) {
22+
return new Hono<SessionVars>()
23+
.use("/*", sessionAuth)
24+
.get("/whoami", (c) => c.json({ sessionUser: c.get("sessionUser") }))
25+
.get("/private", requireSessionUser, (c) => c.json({ ok: true }))
26+
.get("/admin-only", requireAdminUser, (c) => c.json({ ok: true }))
27+
.onError((err, c) => respondError(c, err));
28+
}
29+
30+
function env(auth: Pick<Fetcher, "fetch">) {
31+
return { AUTH: auth } as unknown as Env;
32+
}
33+
34+
describe("sessionAuth", () => {
35+
it("sets sessionUser to null when there is no cookie/session", async () => {
36+
const auth = stubAuth(() => new Response(JSON.stringify(null), { status: 200 }));
37+
const res = await appWith(auth).request("/whoami", {}, env(auth));
38+
expect(await res.json()).toEqual({ sessionUser: null });
39+
});
40+
41+
it("sets sessionUser to null when the auth worker returns malformed JSON", async () => {
42+
const auth = stubAuth(() => new Response("not json", { status: 200 }));
43+
const res = await appWith(auth).request("/whoami", {}, env(auth));
44+
expect(res.status).toBe(200);
45+
expect(await res.json()).toEqual({ sessionUser: null });
46+
});
47+
48+
it("sets sessionUser to null when the auth worker fetch throws", async () => {
49+
const auth: Pick<Fetcher, "fetch"> = {
50+
fetch: (() => {
51+
throw new Error("network down");
52+
}) as Fetcher["fetch"],
53+
};
54+
const res = await appWith(auth).request("/whoami", {}, env(auth));
55+
expect(await res.json()).toEqual({ sessionUser: null });
56+
});
57+
58+
it("sets sessionUser for a valid non-admin user, and requireAdminUser 403s", async () => {
59+
const user = { id: "u1", email: "a@b.com", name: "A", role: "user" };
60+
const auth = stubAuth(
61+
() => new Response(JSON.stringify({ session: {}, user }), { status: 200 }),
62+
);
63+
const whoami = await appWith(auth).request("/whoami", {}, env(auth));
64+
expect(await whoami.json()).toEqual({ sessionUser: user });
65+
66+
const priv = await appWith(auth).request("/private", {}, env(auth));
67+
expect(priv.status).toBe(200);
68+
69+
const adminOnly = await appWith(auth).request("/admin-only", {}, env(auth));
70+
expect(adminOnly.status).toBe(403);
71+
});
72+
73+
it("allows requireAdminUser for a session user with role admin", async () => {
74+
const user = { id: "u2", email: "admin@b.com", name: "Admin", role: "admin" };
75+
const auth = stubAuth(
76+
() => new Response(JSON.stringify({ session: {}, user }), { status: 200 }),
77+
);
78+
const res = await appWith(auth).request("/admin-only", {}, env(auth));
79+
expect(res.status).toBe(200);
80+
});
81+
82+
it("requireSessionUser 401s when there is no session", async () => {
83+
const auth = stubAuth(() => new Response(JSON.stringify(null), { status: 200 }));
84+
const res = await appWith(auth).request("/private", {}, env(auth));
85+
expect(res.status).toBe(401);
86+
});
87+
});

apps/api/src/session-auth.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Session verification over the `AUTH` service binding (plan D1 Phase 2).
3+
* Forwards Cookie/Authorization to the auth worker's `get-session` endpoint —
4+
* no public hop, no CORS. Does not replace `workspaceAuth`/`adminAuth`
5+
* (bearer tokens); this is the seam for session-authenticated admin UI
6+
* endpoints later phases build on.
7+
*/
8+
import { ForbiddenError, UnauthorizedError } from "@uploads/errors";
9+
import type { MiddlewareHandler } from "hono";
10+
11+
/** Minimal shape of Better Auth's `get-session` response body. */
12+
export interface SessionUser {
13+
id: string;
14+
email: string;
15+
name: string;
16+
role?: string | null;
17+
[key: string]: unknown;
18+
}
19+
20+
interface GetSessionResponse {
21+
session: unknown;
22+
user: SessionUser;
23+
}
24+
25+
export type SessionVars = {
26+
Variables: {
27+
sessionUser: SessionUser | null;
28+
};
29+
Bindings: Env;
30+
};
31+
32+
// Host is unused for routing on a direct service-binding fetch() call — it
33+
// just needs to be a valid absolute URL. Matches the convention already used
34+
// for auth.uploads.sh callers elsewhere in this repo's plan (D1).
35+
const AUTH_INTERNAL_ORIGIN = "https://auth.internal";
36+
37+
/**
38+
* Resolves the caller's session via the AUTH binding and sets `sessionUser`
39+
* (null when there is no valid session, or the auth worker's response is
40+
* missing/malformed — never throws on that path, since most routes should
41+
* treat "not signed in" as a normal state, not a hard failure).
42+
*/
43+
export const sessionAuth: MiddlewareHandler<SessionVars> = async (c, next) => {
44+
c.set("sessionUser", await resolveSessionUser(c.env, c.req.raw));
45+
await next();
46+
};
47+
48+
async function resolveSessionUser(env: Env, req: Request): Promise<SessionUser | null> {
49+
const headers = new Headers();
50+
const cookie = req.headers.get("cookie");
51+
const authorization = req.headers.get("authorization");
52+
if (cookie) headers.set("cookie", cookie);
53+
if (authorization) headers.set("authorization", authorization);
54+
55+
try {
56+
const response = await env.AUTH.fetch(`${AUTH_INTERNAL_ORIGIN}/api/auth/get-session`, {
57+
headers,
58+
});
59+
if (!response.ok) return null;
60+
const body = (await response.json().catch(() => null)) as GetSessionResponse | null;
61+
if (!body || typeof body !== "object" || !body.user) return null;
62+
return body.user;
63+
} catch {
64+
return null;
65+
}
66+
}
67+
68+
/** 401s unless `sessionAuth` found a valid session. */
69+
export const requireSessionUser: MiddlewareHandler<SessionVars> = async (c, next) => {
70+
if (!c.get("sessionUser")) throw new UnauthorizedError();
71+
await next();
72+
};
73+
74+
/** 403s unless the session user has the global `admin` role (D3's admin plugin). */
75+
export const requireAdminUser: MiddlewareHandler<SessionVars> = async (c, next) => {
76+
const user = c.get("sessionUser");
77+
if (!user) throw new UnauthorizedError();
78+
if (user.role !== "admin") throw new ForbiddenError("admin role required");
79+
await next();
80+
};

apps/api/wrangler.jsonc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@
4242
"migrations_dir": "migrations",
4343
},
4444
],
45+
// Service binding to apps/auth (Phase 2, plan D1): forwards Cookie/
46+
// Authorization to GET /api/auth/get-session over the binding — no public
47+
// hop, no CORS. See src/session-auth.ts.
48+
"services": [
49+
{
50+
"binding": "AUTH",
51+
"service": "uploads-auth",
52+
},
53+
],
4554
// Rate limits use separate namespaces so public invite traffic cannot consume
4655
// a workspace's write quota. Both are per-colo rather than globally exact.
4756
"unsafe": {

apps/auth/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# @uploads/auth
2+
3+
Dedicated Better Auth worker for uploads.sh (`auth.uploads.sh`). GitHub OAuth
4+
5+
- magic-link sign-in, its own D1 database (`uploads-auth`), and a small
6+
`/internal/*` API reachable only via the `AUTH` service binding from
7+
`apps/api`. See `docs/superpowers/plans/2026-07-12-better-auth-introduction.md`
8+
for the full design.
9+
10+
## First admin
11+
12+
No one has the global `admin` role (Better Auth's `admin` plugin) until you
13+
grant it. Primary path — after the first human signs in, run:
14+
15+
```bash
16+
wrangler d1 execute uploads-auth --remote --command \
17+
"UPDATE user SET role = 'admin' WHERE email = 'someone@example.com';"
18+
```
19+
20+
See `scripts/promote-admin.sql` for the checked-in reference. Fallback: `POST
21+
/admin/users/promote` on `apps/api` (`ADMIN_TOKEN`-gated), which proxies to
22+
this worker's `/internal/promote-admin` over the service binding — useful
23+
when D1 console access is inconvenient.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Phase 2: `admin` plugin (see src/auth.ts, plan D3/D9).
2+
--
3+
-- `user.role`/`banned`/`ban_reason`/`ban_expires` were already added in the
4+
-- Phase 1 migration (migrations/20260712200000_better_auth_core.sql) —
5+
-- verified against that file's CREATE TABLE for `user` before writing this
6+
-- one, so this migration only needs the column the admin plugin's
7+
-- impersonation feature writes to `session`, which Phase 1 didn't anticipate.
8+
-- Paired with src/schema.ts's `session.impersonatedBy`.
9+
10+
ALTER TABLE session ADD COLUMN impersonated_by TEXT;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- First-admin bootstrap (plan D9). Primary path: run this after the first
2+
-- human signs in via GitHub or magic link. D1 does not support `?`
3+
-- positional params in a plain .sql file run via `wrangler d1 execute
4+
-- --file`, so this is documented as a one-liner substitution rather than run
5+
-- directly as a file.
6+
--
7+
-- Usage (substitute the email, then run from apps/auth):
8+
--
9+
-- wrangler d1 execute uploads-auth --remote --command \
10+
-- "UPDATE user SET role = 'admin' WHERE email = 'someone@example.com';"
11+
--
12+
-- Fallback if D1 console access is inconvenient (e.g. from CI/ops tooling):
13+
-- POST /admin/users/promote on apps/api, ADMIN_TOKEN-gated, body {"email": "..."}.
14+
-- See apps/api/src/routes/admin.ts.
15+
16+
UPDATE user SET role = 'admin' WHERE email = '<REPLACE_WITH_EMAIL>';

0 commit comments

Comments
 (0)