Repository Public MIT
1import { afterAll, beforeAll, describe, expect, it } from "vitest";2import { ipAllowed, login, principalFromSession, reauthenticate, changePassword, bootstrapOwner, createToken } from "../../apps/web/src/server/auth";3import { redactDetail } from "../../apps/web/src/server/audit";4import { makeCtx } from "../../apps/web/src/server/context";5import { resetRateLimits } from "../../apps/web/src/server/ratelimit";6import { addUser, call, publish, setupTestEnv, skillFiles, tokenFor, type TestEnv } from "../helpers";78let env: TestEnv;9beforeAll(async () => { env = await setupTestEnv(); });10afterAll(async () => env.close());1112describe("owner bootstrap", () => {13 it("fails closed once an administrator exists", async () => {14 await expect(bootstrapOwner(env.local, { email: "second@example.test", displayName: "Second", password: "another-long-password", namespace: "second" })).rejects.toMatchObject({ code: "already_bootstrapped" });15 const [n] = await env.sql`SELECT count(*)::int AS n FROM users WHERE platform_role = 'owner'`;16 expect(n!.n).toBe(1);17 });18 it("cannot be invoked by a network principal", async () => {19 const anon = makeCtx({ sql: env.sql });20 await expect(bootstrapOwner(anon, { email: "x@example.test", displayName: "X", password: "another-long-password", namespace: "xx" })).rejects.toMatchObject({ status: 403 });21 });22 it("is enforced by the database: a second owner row is impossible", async () => {23 await expect(env.sql`INSERT INTO users (email, display_name, platform_role) VALUES ('rogue@example.test', 'Rogue', 'owner')`).rejects.toThrow(/users_single_owner/);24 });25 it("records the bootstrap without credentials", async () => {26 const rows = await env.sql`SELECT detail::text AS d, actor_label FROM audit_events WHERE action = 'admin.bootstrap'`;27 expect(rows).toHaveLength(1);28 expect(rows[0]!.d).not.toContain(env.ownerPassword);29 expect(rows[0]!.actor_label).toContain("local-operator");30 });31 it("has no bootstrap or admin-creation endpoint in the HTTP API", async () => {32 for (const p of ["/admin/bootstrap", "/bootstrap", "/users", "/admin/users"]) expect((await call("POST", p, { body: {} })).status).toBe(404);33 });34});3536describe("scoped API tokens", () => {37 it("stores only a verifier and a prefix, never the token", async () => {38 const { token, id } = await tokenFor(env, env.owner, ["research"]);39 expect(token).toMatch(/^skr_[a-z0-9]{10}_[A-Za-z0-9_-]{43}$/);40 const [row] = await env.sql`SELECT * FROM api_tokens WHERE id = ${id}`;41 const dump = JSON.stringify(row);42 expect(dump).not.toContain(token);43 expect(dump).not.toContain(token.split("_")[2]!);44 expect(row!.token_hash).toMatch(/^[0-9a-f]{64}$/);45 const audit = await env.sql`SELECT detail::text AS d FROM audit_events WHERE target_id = ${id}`;46 expect(audit.map((a) => a.d).join()).not.toContain(token.split("_")[2]!);47 const list = await call("GET", "/me/tokens", { token: (await tokenFor(env, env.owner, [], ["tokens:read"])).token });48 expect(JSON.stringify(list.json)).not.toContain("token_hash");49 expect(list.json.items.find((t: any) => t.id === id)).toMatchObject({ status: "active", namespaces: ["research"] });50 });5152 it("authenticates, and reports identity without leaking secrets", async () => {53 const { token } = await tokenFor(env, env.owner, ["research"]);54 const me = await call("GET", "/me", { token });55 expect(me.json.credential).toMatchObject({ kind: "token", namespaces: ["research"], elevated: false });56 expect(me.json.credential.scopes).not.toContain("admin:moderate");57 expect(me.res.headers.get("cache-control")).toContain("no-store");58 expect((await call("GET", "/me")).status).toBe(401);59 expect((await call("GET", "/me", { token: "skr_aaaaaaaaaa_" + "A".repeat(43) })).json.error.code).toBe("invalid_token");60 expect((await call("GET", "/me", { headers: { authorization: "Basic abc" } })).status).toBe(401);61 // right prefix, wrong secret62 const forged = token.slice(0, 15) + "B".repeat(43);63 expect((await call("GET", "/me", { token: forged })).status).toBe(401);64 });6566 it("a revoked token cannot publish", async () => {67 const { token, id } = await tokenFor(env, env.owner, ["research"]);68 expect((await publish(token, skillFiles({ name: "@research/rev-a" }))).status).toBe(201);69 expect((await call("DELETE", `/me/tokens/${id}`, { token })).json.status).toBe("revoked"); // self-revocation needs no extra scope70 const after = await publish(token, skillFiles({ name: "@research/rev-a", version: "1.0.1" }), { create: false });71 expect(after.status).toBe(401);72 expect((await call("GET", "/me", { token })).json.error.code).toBe("token_revoked");73 expect((await call("GET", "/artifacts/research/rev-a/versions")).json.items).toHaveLength(1);74 });7576 it("an expired token cannot publish", async () => {77 const { token, id } = await tokenFor(env, env.owner, ["research"]);78 await env.sql`UPDATE api_tokens SET expires_at = now() - interval '1 second' WHERE id = ${id}`;79 const r = await call("POST", "/artifacts", { token, body: { namespace: "research", slug: "expired", type: "skill", summary: "Should never be created at all." } });80 expect(r.status).toBe(401);81 expect(r.json.error.code).toBe("token_expired");82 expect(r.res.headers.get("www-authenticate")).toContain("Bearer");83 });8485 it("rotation issues a new secret with the same grants and kills the old one", async () => {86 const { token, id } = await tokenFor(env, env.owner, ["research"]);87 const rot = await call("POST", `/me/tokens/${id}/rotate`, { token });88 expect(rot.status).toBe(201);89 expect(rot.json.token).not.toBe(token);90 expect(rot.json).toMatchObject({ namespaces: ["research"], rotatedFrom: id, status: "active" });91 expect((await call("GET", "/me", { token })).status).toBe(401);92 expect((await call("GET", "/me", { token: rot.json.token })).status).toBe(200);93 expect((await publish(rot.json.token, skillFiles({ name: "@research/rotated" }))).status).toBe(201);94 expect((await call("POST", `/me/tokens/${id}/rotate`, { token: rot.json.token })).status).toBe(403); // cannot rotate another token without tokens:write95 });9697 it("enforces scopes on every write", async () => {98 const reader = (await tokenFor(env, env.owner, ["research"], ["artifacts:read"])).token;99 const r = await call("POST", "/artifacts", { token: reader, body: { namespace: "research", slug: "nope", type: "skill", summary: "Read-only tokens cannot create." } });100 expect(r.status).toBe(403);101 expect(r.json.error).toMatchObject({ code: "insufficient_scope", details: { required: "artifacts:create" } });102 expect((await call("GET", "/me/tokens", { token: reader })).status).toBe(403);103 expect((await call("GET", "/me/audit", { token: reader })).status).toBe(403);104 expect((await call("GET", "/admin/audit", { token: reader })).status).toBe(403);105 });106107 it("never lets a token mint something broader than itself", async () => {108 const minter = (await tokenFor(env, env.owner, ["research"], ["tokens:write", "artifacts:read"])).token;109 const esc = await call("POST", "/me/tokens", { token: minter, body: { name: "escalate", scopes: ["releases:publish"], namespaces: ["research"] } });110 expect(esc.json.error.code).toBe("scope_escalation");111 const elev = await call("POST", "/me/tokens", { token: minter, body: { name: "elevate", scopes: ["admin:moderate"] } });112 expect(elev.json.error.code).toBe("elevation_forbidden");113 await env.sql`INSERT INTO namespaces (name, owner_user_id) VALUES ('other-lab', ${env.owner.id})`;114 const ns = await call("POST", "/me/tokens", { token: minter, body: { name: "widen", scopes: ["artifacts:read"], namespaces: ["other-lab"] } });115 expect(ns.json.error.code).toBe("namespace_escalation");116 const ok = await call("POST", "/me/tokens", { token: minter, body: { name: "narrow", scopes: ["artifacts:read"], namespaces: ["research"], expiresInHours: 1 } });117 expect(ok.status).toBe(201);118 expect(ok.json.token).toMatch(/^skr_/);119 });120121 it("restricts elevated tokens to administrators and 24 hours", async () => {122 const bob = await addUser(env, "bob@example.test", "bob");123 await expect(tokenFor(env, bob, [], ["admin:moderate"], 1)).rejects.toMatchObject({ code: "admin_required" });124 await expect(tokenFor(env, env.owner, [], ["admin:moderate"], 48)).rejects.toThrow(/24 hours/);125 const def = await createToken({ ...env.local, principal: { kind: "local", user: env.owner } }, { name: "elevated-default", scopes: ["admin:moderate"] });126 expect(def.row.elevated).toBe(true);127 expect(def.row.expires_at.getTime() - Date.now()).toBeLessThanOrEqual(24 * 3600_000 + 1000);128 await expect(tokenFor(env, env.owner, ["research"], ["artifacts:create"], 24 * 400)).rejects.toThrow(/366 days/);129 await expect(tokenFor(env, env.owner, [], ["artifacts:create"])).rejects.toThrow(/at least one allowed namespace/);130 await expect(tokenFor(env, env.owner, ["research"], ["root:everything"])).rejects.toThrow(/Unknown scopes/);131 });132133 it("honours IP allowlists", async () => {134 expect(ipAllowed("203.0.113.7", [])).toBe(true);135 expect(ipAllowed("203.0.113.7", ["203.0.113.0/24"])).toBe(true);136 expect(ipAllowed("203.0.114.7", ["203.0.113.0/24"])).toBe(false);137 expect(ipAllowed("10.1.2.3", ["10.1.2.3"])).toBe(true);138 const { token } = await createToken({ ...env.local, principal: { kind: "local", user: env.owner } }, { name: "pinned", scopes: ["artifacts:read"], namespaces: ["research"], ipAllowlist: ["198.51.100.0/24"] });139 expect((await call("GET", "/me", { token })).status).toBe(401); // test client comes from 203.0.113.7140 expect((await call("GET", "/me", { token, headers: { "x-forwarded-for": "198.51.100.9" } })).status).toBe(200);141 });142143 it("locks out repeated authentication failures per client", async () => {144 process.env.SKILLS_DISABLE_RATE_LIMIT = "0";145 resetRateLimits();146 const h = { "x-forwarded-for": "192.0.2.99" };147 let last = 0;148 for (let i = 0; i < 25; i++) last = (await call("GET", "/me", { token: "skr_bbbbbbbbbb_" + "C".repeat(43), headers: h })).status;149 expect(last).toBe(429);150 const { token } = await tokenFor(env, env.owner, ["research"]);151 expect((await call("GET", "/me", { token, headers: h })).status).toBe(429); // even a valid token waits out the lockout152 expect((await call("GET", "/me", { token })).status).toBe(200);153 process.env.SKILLS_DISABLE_RATE_LIMIT = "1";154 resetRateLimits();155 });156157 it("writes an attributable audit trail for token use", async () => {158 const { token } = await tokenFor(env, env.owner, ["research"], ["audit:read", "artifacts:create"]);159 await call("POST", "/artifacts", { token, body: { namespace: "research", slug: "audited", type: "workflow", summary: "Created to check the audit trail." } });160 const log = await call("GET", "/me/audit?action=artifact.", { token });161 expect(log.json.items[0]).toMatchObject({ action: "artifact.create", target: "@research/audited", result: "success", ip: "203.0.113.7" });162 expect(log.json.items[0].actor).toMatch(/^token:[a-z0-9]{10}\(test-/);163 expect(log.json.items[0].requestId).toMatch(/^[0-9a-f-]{36}$/);164 });165166 it("redacts secrets from audit details as defence in depth", () => {167 const tok = "skr_abcdefghij_" + "D".repeat(43);168 expect(JSON.stringify(redactDetail({ password: "hunter2", nested: { apiToken: "x", note: `leaked ${tok}` } }))).toBe('{"password":"[redacted]","nested":{"apiToken":"[redacted]","note":"leaked skr_[redacted]"}}');169 });170});171172describe("human sessions", () => {173 it("logs in, requires step-up for token creation, and rejects bad passwords uniformly", async () => {174 const ctx = makeCtx({ sql: env.sql, ip: "198.51.100.20", userAgent: "vitest" });175 await expect(login(ctx, "owner@example.test", "wrong-password-000")).rejects.toMatchObject({ code: "invalid_credentials" });176 await expect(login(ctx, "nobody@example.test", "wrong-password-000")).rejects.toMatchObject({ code: "invalid_credentials" });177 const s = await login(ctx, "owner@example.test", env.ownerPassword);178 const principal = await principalFromSession(ctx, s.sessionToken);179 expect(principal.kind).toBe("session");180 const [row] = await env.sql`SELECT token_hash FROM sessions ORDER BY created_at DESC LIMIT 1`;181 expect(row!.token_hash).not.toBe(s.sessionToken);182 const sctx = { ...ctx, principal };183 // Fresh login counts as recent authentication…184 expect((await createToken(sctx, { name: "from-session", scopes: ["artifacts:read"], namespaces: ["research"] })).token).toMatch(/^skr_/);185 // …but an old session must re-enter the password.186 await env.sql`UPDATE sessions SET reauth_at = now() - interval '1 hour'`;187 const stale = { ...ctx, principal: await principalFromSession(ctx, s.sessionToken) };188 await expect(createToken(stale, { name: "stale", scopes: ["artifacts:read"], namespaces: ["research"] })).rejects.toMatchObject({ code: "reauth_required" });189 await expect(reauthenticate(stale, "wrong-password-000")).rejects.toMatchObject({ code: "invalid_credentials" });190 await reauthenticate(stale, env.ownerPassword);191 expect((await createToken(stale, { name: "after-reauth", scopes: ["artifacts:read"], namespaces: ["research"] })).row.name).toBe("after-reauth");192 await changePassword(stale, env.ownerPassword, "a-brand-new-password-789");193 await expect(login(ctx, "owner@example.test", env.ownerPassword)).rejects.toMatchObject({ code: "invalid_credentials" });194 expect((await principalFromSession(ctx, "not-a-real-session-token-000000")).kind).toBe("anonymous");195 });196197 it("requires same-origin and a CSRF token for cookie-authenticated writes", async () => {198 const ctx = makeCtx({ sql: env.sql, ip: "198.51.100.21", userAgent: "vitest" });199 const s = await login(ctx, "owner@example.test", "a-brand-new-password-789");200 const cookie = `skills_session=${s.sessionToken}`;201 const body = { namespace: "research", slug: "csrf", type: "skill", summary: "Cross-site request forgery probe." };202 expect((await call("POST", "/artifacts", { body, headers: { cookie } })).json.error.code).toBe("csrf");203 expect((await call("POST", "/artifacts", { body, headers: { cookie, origin: "https://evil.example" } })).json.error.code).toBe("csrf");204 expect((await call("POST", "/artifacts", { body, headers: { cookie, origin: "http://registry.test", "x-csrf-token": "guess" } })).json.error.code).toBe("csrf");205 expect((await call("GET", "/me", { headers: { cookie } })).json.credential.kind).toBe("session");206 });207});208