Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
47 changes: 47 additions & 0 deletions e2e/agent-token.drizzle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
hashAgentToken,
mintAgentToken,
mintRunScopedToken,
recordTokenUse,
revokeAgentToken,
verifyAgentToken,
} from "../src/tokens";
Expand Down Expand Up @@ -759,4 +760,50 @@ describeIfDb("agent tokens", () => {
await close();
}
});

test("recordTokenUse touches lastUsedAt", async () => {
const { db, close } = createDB({ ...target, schema: SCHEMA });
try {
const tenantId = `tnt_rec_${randomUUID().slice(0, 8)}`;
await seedTenant(db, tenantId);

const minted = await mintAgentToken(db, {
tenantId,
definitionId: "def_artifacts",
name: "artifacts",
});

// Verify lastUsedAt is null initially
const { agentTokenTable } = await import("../src/schema");
const [before] = await db
.select()
.from(agentTokenTable)
.where(eq(agentTokenTable.id, minted.id));
expect(before!.lastUsedAt).toBeNull();

// Call recordTokenUse
const beforeUse = Date.now();
await recordTokenUse(db, minted.id);

// Verify lastUsedAt is now set and recent
const [after] = await db
.select()
.from(agentTokenTable)
.where(eq(agentTokenTable.id, minted.id));
expect(after!.lastUsedAt).toBeInstanceOf(Date);
expect(after!.lastUsedAt!.getTime()).toBeGreaterThanOrEqual(beforeUse);
expect(after!.lastUsedAt!.getTime()).toBeLessThanOrEqual(Date.now());

// A second call advances the timestamp
const firstUsedAt = after!.lastUsedAt!.getTime();
await recordTokenUse(db, minted.id);
const [again] = await db
.select()
.from(agentTokenTable)
.where(eq(agentTokenTable.id, minted.id));
expect(again!.lastUsedAt!.getTime()).toBeGreaterThanOrEqual(firstUsedAt);
} finally {
await close();
}
});
});
1 change: 1 addition & 0 deletions migrations/0003_last_used_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "agent_token"."token" ADD COLUMN IF NOT EXISTS "last_used_at" timestamptz;
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
mintAgentToken,
mintRunScopedToken,
recordTokenUse,
revokeAgentToken,
verifyAgentToken,
type AgentTokenDb,
Expand Down
38 changes: 36 additions & 2 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Context, MiddlewareHandler } from "hono";

import {
bearerFromAuthorization,
recordTokenUse,
verifyAgentToken,
type AgentTokenDb,
} from "./tokens.js";
Expand All @@ -23,6 +24,7 @@ export type AgentTokenVariables = { agentToken: AgentTokenContext };

export type RequireAgentTokenOpts<TSchema extends Record<string, unknown>> = {
db: AgentTokenDb<TSchema>;
recordUse?: boolean;
};

/** A host-side verifier, for mounts that want to authenticate a bearer
Expand Down Expand Up @@ -55,12 +57,29 @@ export function createAgentTokenVerifier<
>(opts: RequireAgentTokenOpts<TSchema>): AgentTokenVerifier {
// The tenant is read before the header, so a host missing the tenant
// middleware rejects every request, not only valid tokens.
return async (c) =>
verifyAuthorization(
return async (c) => {
const identity = await verifyAuthorization(
opts.db,
c.get("tenant").id,
c.req.header("authorization"),
);
if (identity !== undefined && opts.recordUse) {
recordTokenUse(opts.db, identity.id).catch((err: unknown) => {
console.error("recordTokenUse failed (verifier)", err);
});
console.info(
JSON.stringify({
event: "agent_token_used",
tokenId: identity.id,
tenantId: identity.tenantId,
definitionId: identity.definitionId,
route: c.req.path,
method: c.req.method,
}),
);
}
return identity;
};
}

/** Middleware that verifies a bearer and sets `agentToken` on the context.
Expand Down Expand Up @@ -91,6 +110,21 @@ export function requireAgentToken<TSchema extends Record<string, unknown>>(
}
if (identity === undefined) return c.json(unauthorized, 401);
c.set("agentToken", identity);
if (opts.recordUse) {
recordTokenUse(opts.db, identity.id).catch((err: unknown) => {
console.error("recordTokenUse failed (middleware)", err);
});
console.info(
JSON.stringify({
event: "agent_token_used",
tokenId: identity.id,
tenantId: identity.tenantId,
definitionId: identity.definitionId,
route: c.req.path,
method: c.req.method,
}),
);
}
await next();
return undefined;
};
Expand Down
1 change: 1 addition & 0 deletions src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function mountAgentTokens<
name: agentTokenTable.name,
createdAt: agentTokenTable.createdAt,
revokedAt: agentTokenTable.revokedAt,
lastUsedAt: agentTokenTable.lastUsedAt,
})
.from(agentTokenTable)
.where(eq(agentTokenTable.tenantId, c.get("tenant").id))
Expand Down
1 change: 1 addition & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ export const agentTokenTable = agentTokenSchema.table("token", {
.notNull()
.defaultNow(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
});
12 changes: 12 additions & 0 deletions src/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ export async function mintRunScopedToken<
};
}

/** Updates the last-used timestamp on a token row. Fire after a
* successful verification to leave an audit trail. */
export async function recordTokenUse<TSchema extends Record<string, unknown>>(
db: AgentTokenDb<TSchema>,
tokenId: string,
): Promise<void> {
await db
.update(agentTokenTable)
.set({ lastUsedAt: new Date() })
.where(eq(agentTokenTable.id, tokenId));
}

/** Reads a `Bearer` credential out of an `Authorization` header value. */
export function bearerFromAuthorization(
header: string | undefined,
Expand Down
Loading