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
20 changes: 14 additions & 6 deletions AUTH_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,16 @@ scopes = ["openid", "profile", "email", "offline_access"]
endpoints `/oauth2/authorize` + `/oauth2/token`.
- **`/api/v1/memory/rpc`** — memory data plane + space management. Auth: api key
**or** OAuth access token (Bearer) **or** cookie; requires `X-Me-Space`.
- **`/api/v1/user/rpc`** — user-scoped (whoami, agent/api-key/space management).
Auth: OAuth access token (Bearer), cookie, **or the user's own api key (PAT)**.
An **agent** key is rejected here (403 — agents can't manage the account), and
`apiKey.create` / `apiKey.delete` reject *any* key-authenticated caller
(session-only: a key can't mint or revoke keys).
- **`/api/v1/user/rpc`** — account-scoped (whoami, agent/api-key/space management).
Auth: OAuth access token (Bearer), cookie, **or an api key** (a user PAT or an
agent key). Authentication establishes *who*, not *what*: the door admits any
authenticated principal and a per-method **allow-list** authorizes. An **agent**
key validates to its agent principal, so the two account-scoped *reads* on the
allow-list — `whoami` and `space.list` — work for it; every other method is
account *management* and stays user-only (the gate calls `requireUserCaller`,
so a new method is default-denied to agents unless explicitly allow-listed).
`apiKey.create` / `apiKey.delete` additionally reject *any* key-authenticated
caller (session-only: a key can't mint or revoke keys).
- **`/`** (any non-`/api` GET) — the web UI (static SPA + fallback), including the
`/login` page below.

Expand Down Expand Up @@ -244,7 +249,10 @@ A key can be minted for two kinds of member (`apiKey.create({ memberId })`,
gated by `requireOwnMember` — the caller's own user or an owned agent):

- **Agent key** (kind `'a'`) — a headless service account, scoped to the agent's
grants. Memory RPC only; rejected on the user RPC.
grants. Drives the full memory RPC (data plane + the space management its
grants authorize), plus the account-scoped *reads* on the user RPC (`whoami`,
`space.list`) — but not account management (it owns no agents/keys/spaces and
is never an admin), which the user RPC denies per-method.
- **User PAT** (kind `'u'`, the caller's own principal) — "be me, headless"
(`me apikey create --self`; explicit opt-in, since it's a full-access
credential). Authenticates as
Expand Down
25 changes: 24 additions & 1 deletion e2e/cli.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,11 +667,34 @@ describe.skipIf(
// Search with ONLY the api key — no session token. The agent's global key
// plus X-Me-Space (ME_SPACE) selects the space; this exercises the CLI's
// api-key auth path against the real server end-to-end.
const agentEnv = { ME_API_KEY: key.key, ME_SESSION_TOKEN: "" };
const res = await meJson<{ total: number }>(
["search", "--fulltext", "fox"],
{ ME_API_KEY: key.key, ME_SESSION_TOKEN: "" },
agentEnv,
);
expect(res.total).toBeGreaterThan(0);

// TNT-139: the same agent key now drives the account-scoped *reads* on the
// user RPC too — authn establishes *who*, the server authorizes per-method.
// whoami reports the agent's own identity (kind "a", no email).
const who = await meJson<{
identity: { id: string; kind: string; email: string | null };
}>(["whoami"], agentEnv);
expect(who.identity.id).toBe(agent.id);
expect(who.identity.kind).toBe("a");
expect(who.identity.email).toBeNull();

// space.list returns the spaces the agent is admitted to.
const spaces = await meJson<{ spaces: { slug: string }[] }>(
["space", "list"],
agentEnv,
);
expect(spaces.spaces.some((s) => s.slug === spaceSlug)).toBe(true);

// …but account management stays user-only: the CLI no longer pre-empts with
// a session gate, so the server's FORBIDDEN surfaces instead (non-zero exit).
const denied = await me(["agent", "list"], agentEnv);
expect(denied.code).not.toBe(0);
});

test("8. `me claude import` backfills work that predates the hook", async () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ export function createWhoamiCommand(): Command {
fmt,
() => {
console.log(` Name: ${identity.name}`);
console.log(` Email: ${identity.email}`);
console.log(
` Kind: ${identity.kind === "a" ? "agent" : "user"}`,
);
// Agents have no email (null); humans always have one.
if (identity.email !== null)
console.log(` Email: ${identity.email}`);
console.log(` ID: ${identity.id}`);
console.log(` Server: ${creds.server}`);
if (creds.activeSpace) {
Expand Down
16 changes: 11 additions & 5 deletions packages/protocol/user/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
/**
* whoami method schema for the user RPC.
*
* Returns the identity behind the session token — used by the CLI for `me login`
* confirmation and `me whoami`. Session-only (an api key never authenticates the
* user endpoint).
* Returns the identity behind the credential — used by the CLI for `me login`
* confirmation and `me whoami`. Admits any authenticated principal: a human
* (session / OAuth / user PAT) reports `kind: "u"` with their email; an agent
* (acting with its api key) reports `kind: "a"` with a null email (agents have
* no email). Account-management on the user RPC stays user-only — see the
* per-method authorization in the server's user handlers.
*/
import { z } from "zod";

// whoami — the authenticated user's identity
// whoami — the authenticated principal's identity
export const whoamiParams = z.object({});
export type WhoamiParams = z.infer<typeof whoamiParams>;

export const whoamiResult = z.object({
id: z.string(),
email: z.string(),
/** The authenticated principal's kind: a user ("u") or an agent ("a"). */
kind: z.enum(["u", "a"]),
/** The user's email; null for an agent (agents have no email). */
email: z.string().nullable(),
name: z.string(),
});
export type WhoamiResult = z.infer<typeof whoamiResult>;
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Integration test for user-RPC authentication (authenticateUser).
//
// Covers the three admitted credentials + the bars: an OAuth access token and
// the user's OWN api key (a PAT) authenticate as the user; an AGENT api key is
// rejected here (agents can't manage the account); invalid/missing → 401.
// Covers the admitted credentials: an OAuth access token and the user's OWN api
// key (a PAT) authenticate as the user (kind 'u'); an AGENT api key is admitted
// as kind 'a' (per-method authz, not a door bar, keeps it off account
// management); invalid/missing → 401.
// TEST_DATABASE_URL="postgresql://postgres@127.0.0.1:5432/postgres" \
// bun test --timeout 30000 \
// packages/server/middleware/authenticate-user.integration.test.ts
Expand Down Expand Up @@ -144,13 +145,22 @@ test("a PAT for an unverified user reports emailVerified=false (read from the DB
if (result.ok) expect(result.context.emailVerified).toBe(false);
});

test("an agent api key is forbidden on the user RPC (403)", async () => {
test("an agent api key is admitted on the user RPC as kind 'a' (no email)", async () => {
// Authn establishes *who*; it no longer doubles as the authz gate. An agent
// key validates to its agent principal so the account-scoped reads (whoami,
// space.list) work; the per-method handlers still deny account management.
const userId = await seedUser();
const agentId = await core.createAgent(userId, `agent-${rand()}`);
const key = await core.createApiKey(agentId, "ci");
const result = await auth(engineCore.formatApiKey(key.lookupId, key.secret));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.status).toBe(403);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.context.kind).toBe("a");
expect(result.context.userId).toBe(agentId);
expect(result.context.viaApiKey).toBe(true);
expect(result.context.email).toBeNull();
expect(result.context.emailVerified).toBe(false);
}
});

test("an invalid api key → 401", async () => {
Expand Down
82 changes: 52 additions & 30 deletions packages/server/middleware/authenticate-user.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
/**
* Authentication for the user RPC (`/api/v1/user/rpc`).
*
* User-scoped: resolves the calling human (a user principal) from one of three
* credentials — an OAuth access token (CLI/MCP), the browser cookie session, or
* the user's own api key (a personal access token, for headless/CLI use). Only
* the caller's OWN (kind 'u') key is admitted: an AGENT key is barred here
* (agents can't manage the account) → 403.
* Resolves the calling principal from one of three credentials — an OAuth access
* token (CLI/MCP), the browser cookie session, or an api key (a user PAT or an
* agent key, for headless/CLI use). Authentication establishes *who*; it no
* longer doubles as the authorization gate. Any authenticated principal is
* admitted here so the account-scoped *reads* (`whoami`, `space.list`) work for
* an agent acting with `ME_API_KEY`; the account-*management* methods stay
* user-only, enforced by the user-RPC gate's allow-list (`gateAgentAccess` +
* `requireUserCaller`). Sessions / OAuth tokens are always users; an api key
* carries its principal's real kind.
*/
import { type CoreStore, parseApiKey } from "@memory.build/engine/core";
import { debug, span } from "@pydantic/logfire-node";
Expand All @@ -19,22 +23,29 @@ import { extractBearerToken, passesCsrfCheck } from "./authenticate";

export interface UserAuthContext {
type: "user";
/** The authenticated user id (== the core user-principal id). */
/** The authenticated principal's kind: a user ("u") or an agent ("a"). */
kind: "u" | "a";
/** The authenticated principal id (a user-principal id, or an agent's). */
userId: string;
/** The user's email (powers whoami + lazy provisioning). */
email: string;
/** The user's display name. */
/** The user's email (powers whoami + lazy provisioning); null for an agent. */
email: string | null;
/**
* The principal's name. From a session / OAuth token this is the human's
* display name; on the api-key path it's the core principal's name — which is
* the user's email for a user PAT, or the agent's name for an agent.
*/
name: string;
/**
* Whether the identity provider verified the email. Gates email-keyed
* provisioning steps (invitation redemption) — invitations are addressed by
* email, so an unverified address must not auto-join its invited spaces.
* Always false for an agent (no email).
*/
emailVerified: boolean;
/**
* True when authenticated by an api key (a user PAT) rather than a session /
* OAuth token. The handler layer uses this to keep key mint/revoke
* session-only (a key can't manage keys).
* True when authenticated by an api key (a user PAT or an agent key) rather
* than a session / OAuth token. The handler layer uses this to keep key
* mint/revoke session-only (a key can't manage keys).
*/
viaApiKey: boolean;
}
Expand All @@ -56,10 +67,10 @@ export async function authenticateUser(
callback: async () => {
const bearer = extractBearerToken(request);
if (bearer) {
// A user PAT (api key). Only the caller's OWN (kind 'u') principal is
// admitted here; an agent key is a valid credential but not for the user
// API (agents can't manage the account) → 403. An api key is never an
// OAuth token, so this branch always returns.
// An api key — a user PAT (kind 'u') or an agent key (kind 'a'). Both
// are admitted; the per-method handlers authorize what each may do (an
// agent gets `whoami` / `space.list`, nothing that manages the account).
// An api key is never an OAuth token, so this branch always returns.
const parsed = parseApiKey(bearer);
if (parsed) {
const validated = await core.validateApiKey(
Expand All @@ -74,30 +85,39 @@ export async function authenticateUser(
};
}
const principal = await core.getPrincipal(validated.memberId);
if (!principal || principal.kind !== "u") {
debug("user auth failed: agent api key on the user RPC");
// A key only ever resolves to a user or an agent (groups hold no key);
// a missing principal means a member torn down under a live key.
if (!principal || principal.kind === "g") {
debug("user auth failed: api key resolves to no usable principal");
return {
ok: false,
error: forbidden(
"Agent API keys can't access the user API; use a session or a user key.",
),
error: unauthorized("Invalid or expired token"),
};
}
debug("user auth succeeded (user pat)", { userId: principal.id });
const isUser = principal.kind === "u";
debug("user auth succeeded (api key)", {
userId: principal.id,
kind: principal.kind,
});
return {
ok: true,
context: {
type: "user",
kind: principal.kind,
userId: principal.id,
// The core user principal's name is the email; the display name
// lives on auth.users (not fetched on the key path).
email: principal.name,
// For a user the core principal's name IS the email (the display
// name lives on auth.users, not fetched on the key path); an agent
// has no email — its name is its display name.
email: isUser ? principal.name : null,
name: principal.name,
// Carry the real verified flag (the same fact a session reports),
// so a PAT behaves like any other credential — including the
// email-keyed redemption step. A PAT's only carve-out is that it
// can't mint/revoke keys (enforced at the handler layer).
emailVerified: await getUserEmailVerified(principal.id),
// For a user PAT, carry the real verified flag (the same fact a
// session reports), so it behaves like any other credential —
// including the email-keyed redemption step. An agent has no
// email to verify. A key's only carve-out is that it can't
// mint/revoke keys (enforced at the handler layer).
emailVerified: isUser
? await getUserEmailVerified(principal.id)
: false,
viaApiKey: true,
},
};
Expand All @@ -117,6 +137,7 @@ export async function authenticateUser(
ok: true,
context: {
type: "user",
kind: "u",
userId: verified.userId,
email: verified.email,
name: verified.name,
Expand Down Expand Up @@ -153,6 +174,7 @@ export async function authenticateUser(
ok: true,
context: {
type: "user",
kind: "u",
userId: user.id,
email: user.email,
name: user.name,
Expand Down
25 changes: 15 additions & 10 deletions packages/server/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ export function createRouter(ctx: ServerContext): Router {
};
});

// User RPC (new model): session-only, user-scoped (agent lifecycle)
// User RPC (new model): account-scoped. Admits any authenticated principal;
// the handlers authorize per-method (an agent gets whoami / space.list only).
const userRpcHandler = createRpcHandler(userMethods, async (request) => {
const result = await authenticateUser(
request,
Expand All @@ -185,19 +186,23 @@ export function createRouter(ctx: ServerContext): Router {
if (!result.ok) {
return result.error;
}
const { userId, email, name, emailVerified, viaApiKey } = result.context;
const { kind, userId, email, name, emailVerified, viaApiKey } =
result.context;
// Lazy first-login provisioning: stand up the core principal + default space
// the first time a better-auth user reaches the user RPC (idempotent no-op
// thereafter). The CLI hits whoami/space.list right after login, so this is
// the natural first touchpoint. `emailVerified` gates the email-keyed
// invitation-redemption step.
await ensureUserProvisioned(
db,
core,
{ core: coreSchema },
{ userId, email, emailVerified },
);
return { core, userId, email, name, db, coreSchema, viaApiKey };
// invitation-redemption step. USERS only — an agent is already provisioned
// by its owner, and provisioning is user+email keyed (an agent has neither).
if (kind === "u" && email !== null) {
await ensureUserProvisioned(
db,
core,
{ core: coreSchema },
{ userId, email, emailVerified },
);
}
return { core, kind, userId, email, name, db, coreSchema, viaApiKey };
});

/**
Expand Down
16 changes: 9 additions & 7 deletions packages/server/rpc/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ export async function handleRpcRequest(
return json(methodNotFound(rpcRequest.method, requestId));
}

const handlerContext: HandlerContext = { request, ...context };

// Authorize before validating params: a caller that may not invoke this
// method shouldn't have its input parsed — it gets a consistent
// authorization error rather than an INVALID_PARAMS that leaks the param
// schema. Throws an AppError on denial (mapped below).
method.authorize?.(handlerContext);

Comment on lines +139 to +146
// Validate params
const paramsResult = method.schema.safeParse(rpcRequest.params);
if (!paramsResult.success) {
Expand Down Expand Up @@ -170,13 +178,7 @@ export async function handleRpcRequest(
"rpc.request_id": String(requestId),
...identityAttrs,
},
callback: async () => {
const handlerContext: HandlerContext = {
request,
...context,
};
return method.handler(paramsResult.data, handlerContext);
},
callback: async () => method.handler(paramsResult.data, handlerContext),
});

return json(createSuccessResponse(result, requestId ?? 0));
Expand Down
7 changes: 7 additions & 0 deletions packages/server/rpc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export interface RegisteredMethod<TParams = unknown, TResult = unknown> {
schema: z.ZodType<TParams>;
/** Handler function */
handler: MethodHandler<TParams, TResult>;
/**
* Optional per-method authorization, run by the dispatcher BEFORE param
* validation. Throw an AppError to deny (e.g. FORBIDDEN). Gates a method on
* the caller's identity without parsing input it can't use — see the
* user-RPC agent allow-list (`gateAgentAccess`).
*/
authorize?: (context: HandlerContext) => void;
}

/**
Expand Down
Loading