Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/user-api-keys-provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@anticapture/authful": minor
"@anticapture/user-api": minor
---

Add self-service API keys (DEV-950). Authful gains an optional scoped provisioning key that may only mint/revoke `user:*` tenants and cannot list all tenants (the admin key stays unrestricted). The User API brokers end-user keys through it: `POST/GET/DELETE /me/api-keys` (session-authenticated) mint into Authful under tenant `user:<userId>`, return the plaintext exactly once, and store only ownership (never the secret) — with a per-user quota and Authful-first revocation. Both surfaces stay disabled until their env is configured.
11 changes: 8 additions & 3 deletions apps/authful/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { validateController } from "@/controllers/validate";
import { exporter } from "@/instrumentation";
import { metricsMiddleware } from "@/middlewares/metrics";
import { requestLogger } from "@/middlewares/logger";
import { scopedTokenAuth } from "@/middlewares/token-auth";
import { logger } from "@/logger";
import type { TokensService } from "@/services/tokens";

Expand All @@ -17,13 +18,15 @@ export type AppConfig = {
db: AuthfulDrizzle;
adminApiKey: string;
internalApiKey: string;
provisioningApiKey?: string;
};

export function createApp({
service,
db,
adminApiKey,
internalApiKey,
provisioningApiKey,
}: AppConfig): Hono {
const app = new Hono();

Expand All @@ -48,9 +51,11 @@ export function createApp({
return c.body(body, 200, { "Content-Type": contentType });
});

// Admin surface: humans minting/listing/revoking tokens.
app.use("/tokens", bearerAuth({ token: adminApiKey }));
app.use("/tokens/*", bearerAuth({ token: adminApiKey }));
// Token-management surface: the admin key (unrestricted) or the optional
// provisioning key (scoped to `user:*`, enforced in the controller).
const tokenAuth = scopedTokenAuth({ adminApiKey, provisioningApiKey });
app.use("/tokens", tokenAuth);
app.use("/tokens/*", tokenAuth);

// Internal surface: Gateful validating tokens.
app.use("/validate", bearerAuth({ token: internalApiKey }));
Expand Down
100 changes: 100 additions & 0 deletions apps/authful/src/controllers/authful.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { TokensService, hashToken } from "@/services/tokens";

const ADMIN_KEY = "test-admin-key-0123456789";
const INTERNAL_KEY = "test-internal-key-0123456789";
const PROVISIONING_KEY = "test-provisioning-key-0123456789";

const adminHeaders = {
Authorization: `Bearer ${ADMIN_KEY}`,
Expand All @@ -32,6 +33,10 @@ const internalHeaders = {
Authorization: `Bearer ${INTERNAL_KEY}`,
"Content-Type": "application/json",
};
const provisioningHeaders = {
Authorization: `Bearer ${PROVISIONING_KEY}`,
"Content-Type": "application/json",
};

describe("authful app", () => {
let client: PGlite;
Expand All @@ -50,6 +55,7 @@ describe("authful app", () => {
db,
adminApiKey: ADMIN_KEY,
internalApiKey: INTERNAL_KEY,
provisioningApiKey: PROVISIONING_KEY,
});
});

Expand Down Expand Up @@ -105,6 +111,18 @@ describe("authful app", () => {
expect(res.status).toBe(200);
});

it("rejects an unknown bearer token", async () => {
const res = await app.request("/tokens", {
method: "POST",
headers: {
Authorization: "Bearer not-a-real-key-000000000000",
"Content-Type": "application/json",
},
body: JSON.stringify({ tenant: "user:1", name: "x" }),
});
expect(res.status).toBe(401);
});

it("returns 503 when the DB probe fails", async () => {
const failingDb = {
execute: () => Promise.reject(new Error("db down")),
Expand All @@ -126,6 +144,88 @@ describe("authful app", () => {
});
});

describe("provisioning scope", () => {
const provMint = (body: Record<string, unknown>) =>
app.request("/tokens", {
method: "POST",
headers: provisioningHeaders,
body: JSON.stringify(body),
});

it("mints a user:* token", async () => {
const res = await provMint({ tenant: "user:abc", name: "my-agent" });
expect(res.status).toBe(201);
const body = (await res.json()) as MintResponse;
expect(body.tenant).toBe("user:abc");
expect(body.token).toMatch(/^act_/);
});

it("cannot mint a first-party (non-user) tenant", async () => {
const res = await provMint({ tenant: "uniswap", name: "sneaky" });
expect(res.status).toBe(403);
const before = await app.request("/tokens", { headers: adminHeaders });
const { items } = (await before.json()) as { items: unknown[] };
expect(items).toHaveLength(0);
});

it("cannot list all tokens (no tenant filter)", async () => {
const res = await app.request("/tokens", {
headers: provisioningHeaders,
});
expect(res.status).toBe(403);
});

it("cannot list a non-user tenant", async () => {
const res = await app.request("/tokens?tenant=uniswap", {
headers: provisioningHeaders,
});
expect(res.status).toBe(403);
});

it("lists only its own user:* tenant", async () => {
await provMint({ tenant: "user:abc", name: "one" });
await provMint({ tenant: "user:abc", name: "two" });
await mint({ tenant: "uniswap" }); // must not leak into the result

const res = await app.request("/tokens?tenant=user:abc", {
headers: provisioningHeaders,
});
expect(res.status).toBe(200);
const { items } = (await res.json()) as {
items: { tenant: string; lastUsedAt: string | null }[];
};
expect(items).toHaveLength(2);
expect(items.every((t) => t.tenant === "user:abc")).toBe(true);
});

it("revokes its own user:* token", async () => {
const minted = (await (
await provMint({ tenant: "user:abc", name: "temp" })
).json()) as MintResponse;
const res = await app.request(`/tokens/${minted.id}`, {
method: "DELETE",
headers: provisioningHeaders,
});
expect(res.status).toBe(204);
});

it("cannot revoke a first-party token — 404, not 403 (no oracle)", async () => {
const firstParty = await mint({ tenant: "uniswap" });
const res = await app.request(`/tokens/${firstParty.id}`, {
method: "DELETE",
headers: provisioningHeaders,
});
expect(res.status).toBe(404);

// The token is untouched — admin can still see it active.
const list = await app.request("/tokens", { headers: adminHeaders });
const { items } = (await list.json()) as {
items: { id: string; revokedAt: string | null }[];
};
expect(items.find((t) => t.id === firstParty.id)?.revokedAt).toBeNull();
});
});

describe("POST /tokens", () => {
it("mints a token and returns the plaintext exactly once", async () => {
const body = await mint({});
Expand Down
50 changes: 47 additions & 3 deletions apps/authful/src/controllers/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi";

import {
ErrorResponseSchema,
ListTokensQuerySchema,
MintTokenBodySchema,
MintTokenResponseSchema,
TokenListResponseSchema,
TokenParamsSchema,
toTokenMetadata,
} from "@/mappers/tokens";
import { USER_TENANT_PREFIX } from "@/middlewares/token-auth";
import type { TokensService } from "@/services/tokens";

const forbiddenResponse = {
description: "Scope not permitted for this operation",
content: { "application/json": { schema: ErrorResponseSchema } },
};

export function tokensController(app: Hono, service: TokensService) {
app.openapi(
createRoute({
Expand All @@ -33,10 +40,23 @@ export function tokensController(app: Hono, service: TokensService) {
"application/json": { schema: MintTokenResponseSchema },
},
},
403: forbiddenResponse,
},
}),
async (c) => {
const body = c.req.valid("json");
// The provisioning key may only mint end-user keys.
if (
c.get("authScope") === "provisioning" &&
!body.tenant.startsWith(USER_TENANT_PREFIX)
) {
return c.json(
{
error: `provisioning scope may only mint ${USER_TENANT_PREFIX}* tenants`,
},
403,
);
}
const { token, plaintext } = await service.mint(body);
return c.json({ ...toTokenMetadata(token), token: plaintext }, 201);
},
Expand All @@ -48,16 +68,36 @@ export function tokensController(app: Hono, service: TokensService) {
operationId: "listTokens",
path: "/tokens",
summary: "List token metadata (never hashes or plaintext)",
description:
"Admin lists all tenants (optionally filtered by ?tenant). The " +
"provisioning scope may list only a single user:* tenant it owns.",
tags: ["tokens"],
request: { query: ListTokensQuerySchema },
responses: {
200: {
description: "All tokens, newest first",
description: "Matching tokens, newest first",
content: { "application/json": { schema: TokenListResponseSchema } },
},
403: forbiddenResponse,
},
}),
async (c) => {
const tokens = await service.list();
const { tenant } = c.req.valid("query");
// Unfiltered listing exposes every tenant's metadata — admin only. The
// provisioning key may list, but only its own user:* tenant (so the User
// API can read its users' keys, e.g. lastUsedAt).
if (
c.get("authScope") !== "admin" &&
(!tenant || !tenant.startsWith(USER_TENANT_PREFIX))
) {
return c.json(
{
error: `provisioning scope may only list a single ${USER_TENANT_PREFIX}* tenant`,
},
403,
);
}
const tokens = await service.list(tenant);
return c.json({ items: tokens.map(toTokenMetadata) }, 200);
},
);
Expand All @@ -81,7 +121,11 @@ export function tokensController(app: Hono, service: TokensService) {
}),
async (c) => {
const { id } = c.req.valid("param");
const revoked = await service.revoke(id);
// Provisioning scope can only revoke `user:*` tokens; a non-matching id
// returns 404 (same as missing) to avoid a first-party-token oracle.
const requireTenantPrefix =
c.get("authScope") === "provisioning" ? USER_TENANT_PREFIX : undefined;
const revoked = await service.revoke(id, { requireTenantPrefix });
if (!revoked) return c.json({ error: "Token not found" }, 404);
return c.body(null, 204);
},
Expand Down
7 changes: 7 additions & 0 deletions apps/authful/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ const envSchema = z
ADMIN_API_KEY: z.string().min(16),
// Guards service-facing endpoints (/validate), shared with Gateful.
INTERNAL_API_KEY: z.string().min(16),
// Optional scoped key for the User API to broker end-user keys: restricted
// to `user:*` tenants (mint/revoke only, no listing). Left unset until the
// User API's key-provisioning feature ships.
PROVISIONING_API_KEY: z.preprocess(
(v) => (v === "" ? undefined : v),
z.string().min(16).optional(),
),
// CI/preview only: a fixed, known token seeded into the DB on boot so every
// service in the same Railway PR preview shares a working API key. Required
// in preview environments; ignored on dev/production. The seeded token's
Expand Down
1 change: 1 addition & 0 deletions apps/authful/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const app = createApp({
db,
adminApiKey: env.ADMIN_API_KEY,
internalApiKey: env.INTERNAL_API_KEY,
provisioningApiKey: env.PROVISIONING_API_KEY,
});

app.doc("/docs/json", {
Expand Down
9 changes: 9 additions & 0 deletions apps/authful/src/mappers/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ export const TokenListResponseSchema = z
.object({ items: z.array(TokenMetadataSchema) })
.openapi("TokenListResponse");

export const ListTokensQuerySchema = z
.object({
tenant: z
.string()
.optional()
.openapi({ description: "Filter to a single tenant's tokens." }),
})
.openapi("ListTokensQuery");

export const MintTokenBodySchema = z
.object({
tenant: z.string().min(1),
Expand Down
57 changes: 57 additions & 0 deletions apps/authful/src/middlewares/token-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createHash, timingSafeEqual } from "node:crypto";

import { createMiddleware } from "hono/factory";

/**
* Tenant prefix reserved for end-user API keys. The provisioning key (used by
* the User API to broker keys on behalf of signed-in users) may only mint and
* revoke tokens under this prefix — never a first-party tenant like "uniswap".
*/
export const USER_TENANT_PREFIX = "user:";

export type AuthScope = "admin" | "provisioning";

// Global context-variable typing so handlers can read c.get("authScope")
// without threading a custom Env generic through the whole app (which would
// break controllers that take the default-typed app).
declare module "hono" {
interface ContextVariableMap {
authScope: AuthScope;
}
}

const digest = (value: string) => createHash("sha256").update(value).digest();

// Constant-time compare over fixed-length digests (raw strings differ in
// length, which timingSafeEqual rejects and which itself leaks length).
const safeEqual = (a: string, b: string) =>
timingSafeEqual(digest(a), digest(b));

const bearer = (header: string | undefined): string | undefined =>
header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;

/**
* Authenticates the token-management surface as one of two scopes:
* - `admin` — the full admin key; unrestricted (mint/list/revoke any tenant).
* - `provisioning` — the optional provisioning key; restricted to `user:*`
* tenants and forbidden from listing (enforced by the controller via
* `c.get("authScope")`).
*/
export const scopedTokenAuth = (opts: {
adminApiKey: string;
provisioningApiKey?: string;
}) =>
createMiddleware(async (c, next) => {
const token = bearer(c.req.header("Authorization"));
if (!token) return c.json({ error: "unauthorized" }, 401);

if (safeEqual(token, opts.adminApiKey)) {
c.set("authScope", "admin");
return next();
Comment on lines +48 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject identical admin and provisioning keys

When Authful is deployed with PROVISIONING_API_KEY accidentally set to the same value as ADMIN_API_KEY, this branch classifies that credential as admin before the provisioning check runs, so the User API’s supposedly scoped key can list tokens and mint/revoke first-party tenants. Since the new rollout depends on the provisioning key being restricted to user:*, reject equal admin/provisioning secrets at startup rather than silently granting admin scope.

Useful? React with 👍 / 👎.

}
if (opts.provisioningApiKey && safeEqual(token, opts.provisioningApiKey)) {
c.set("authScope", "provisioning");
return next();
}
return c.json({ error: "unauthorized" }, 401);
});
Loading
Loading