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>
139 lines
4.6 KiB
TypeScript
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();
|
|
});
|
|
});
|