SPB Git

Repository Public MIT

tokens.test.ts 14.3 KB 208 lines typescript

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
Files main
Menu

Theme