Files
findr-api/test/api-tokens.service.test.ts
T
mars3142 f5a8c63bf6 Add personal access tokens for machine callers
For scripts and tools that call findr-api's HTTP endpoints directly
without an interactive OIDC login - a KiCad plugin, a backup job, a
CLI import script. Not used by the ESP32 controller, which only ever
speaks MQTT to Mosquitto and has nothing to do with this.

- api_tokens table: subject (OIDC sub) + name + a hashed fdr_... token
  + scopes + optional expiry. Plaintext is generated once at creation
  and never stored.
- plugins/auth.ts gains a second verification path: a fdr_-prefixed
  bearer is looked up by hash instead of JWT-verified, then mapped to
  the same request.user shape the existing scope checks already use.
- A token's scopes must be a subset of whatever the creating credential
  itself currently holds (services/api-tokens.service.ts) - no
  self-escalation, enforced server-side regardless of what a client UI
  shows.
- /v1/tokens (list/create/revoke), gated by plain authentication rather
  than a SCOPES.* requireScope - this is about identity, not a findr
  domain permission.

Verified end-to-end against a real Postgres: unauthenticated 401,
valid-token 200/201, scope escalation 403, revoke 204, revoked-token
reuse 401.

Signed-off-by: Peter Siegmund <mars3142@noreply.mars3142.dev>
2026-09-04 23:21:30 +02:00

139 lines
4.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { ForbiddenError, NotFoundError, ValidationError } from "../src/lib/errors.js";
import { SCOPES } from "../src/lib/scopes.js";
import type { ApiToken, ApiTokensRepository } from "../src/repositories/api-tokens.repository.js";
import { createApiTokensService } from "../src/services/api-tokens.service.js";
function fakeToken(over: Partial<ApiToken> = {}): ApiToken {
return {
id: "00000000-0000-0000-0000-000000000001",
subject: "user-1",
name: "KiCad plugin",
tokenHash: "hash",
tokenPrefix: "9c4a2b91",
scopes: [SCOPES.PARTS_READ],
expiresAt: null,
lastUsedAt: null,
revokedAt: null,
createdAt: new Date(),
...over,
};
}
function repoStub(over: Partial<ApiTokensRepository> = {}): ApiTokensRepository {
return {
create: vi.fn(),
listBySubject: vi.fn(),
findByHash: vi.fn(),
findByIdForSubject: vi.fn(),
revoke: vi.fn(),
touchLastUsed: vi.fn(),
...over,
} as unknown as ApiTokensRepository;
}
describe("apiTokensService.create", () => {
it("rejects a scope the caller doesn't hold", async () => {
const service = createApiTokensService(repoStub());
await expect(
service.create(
"user-1",
{ name: "x", scopes: [SCOPES.STOCK_WRITE] },
new Set([SCOPES.PARTS_READ]),
),
).rejects.toBeInstanceOf(ForbiddenError);
});
it("rejects an unknown scope", async () => {
const service = createApiTokensService(repoStub());
await expect(
service.create(
"user-1",
{ name: "x", scopes: ["not-a-real-scope"] },
new Set(["not-a-real-scope"]),
),
).rejects.toBeInstanceOf(ValidationError);
});
it("rejects an empty name", async () => {
const service = createApiTokensService(repoStub());
await expect(
service.create(
"user-1",
{ name: " ", scopes: [SCOPES.PARTS_READ] },
new Set([SCOPES.PARTS_READ]),
),
).rejects.toBeInstanceOf(ValidationError);
});
it("rejects an empty scope list", async () => {
const service = createApiTokensService(repoStub());
await expect(
service.create("user-1", { name: "x", scopes: [] }, new Set([SCOPES.PARTS_READ])),
).rejects.toBeInstanceOf(ValidationError);
});
it("creates a token when every scope is held, returning the plaintext once", async () => {
const created = fakeToken();
const create = vi.fn().mockResolvedValue(created);
const service = createApiTokensService(repoStub({ create }));
const result = await service.create(
"user-1",
{ name: "KiCad plugin", scopes: [SCOPES.PARTS_READ] },
new Set([SCOPES.PARTS_READ, SCOPES.PARTS_WRITE]),
);
expect(result.token).toMatch(/^fdr_/);
expect(result).not.toHaveProperty("tokenHash");
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ subject: "user-1", name: "KiCad plugin", expiresAt: null }),
);
});
it("turns expiresInDays into a future expiresAt", async () => {
const create = vi.fn().mockResolvedValue(fakeToken());
const service = createApiTokensService(repoStub({ create }));
const before = Date.now();
await service.create(
"user-1",
{ name: "x", scopes: [SCOPES.PARTS_READ], expiresInDays: 30 },
new Set([SCOPES.PARTS_READ]),
);
const { expiresAt } = create.mock.calls[0]![0] as { expiresAt: Date };
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + thirtyDaysMs - 1000);
expect(expiresAt.getTime()).toBeLessThanOrEqual(before + thirtyDaysMs + 5000);
});
});
describe("apiTokensService.list", () => {
it("never exposes tokenHash", async () => {
const listBySubject = vi.fn().mockResolvedValue([fakeToken()]);
const service = createApiTokensService(repoStub({ listBySubject }));
const [summary] = await service.list("user-1");
expect(summary).not.toHaveProperty("tokenHash");
expect(summary?.tokenPrefix).toBe("9c4a2b91");
});
});
describe("apiTokensService.revoke", () => {
it("throws NotFoundError when the token doesn't exist (or isn't the caller's)", async () => {
const service = createApiTokensService(
repoStub({ revoke: vi.fn().mockResolvedValue(undefined) }),
);
await expect(service.revoke("user-1", "missing-id")).rejects.toBeInstanceOf(NotFoundError);
});
it("resolves when the repository confirms the revoke", async () => {
const service = createApiTokensService(
repoStub({ revoke: vi.fn().mockResolvedValue(fakeToken({ revokedAt: new Date() })) }),
);
await expect(service.revoke("user-1", fakeToken().id)).resolves.toBeUndefined();
});
});