Skip to content

Commit d3fdd46

Browse files
feat(runtime, rest, plugin-auth): an OAuth agent's api-action forward carries a server-minted forward credential (ADR-0090 D10 rule 5) (#28482)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoEY1cKkXmjt2KhuP1QWGYsM)_ Fixes #28367 Clause-②: yes (widening, at the two named cells only) Metadata-protocol: none This is step 2 of the ruling's order, the code half of the ADR-0090 D10 rule 5 amendment that landed in #28455 (a99de44). Ruling of record 6545950368 (go, as shaped). Ruling of record 6548923676 (batch #436 item 1, A): `api_key` × `identity` keeps refusing. ## 维护者速读 - 改了什么:OAuth 连接的 AI agent 运行 `api` 动作时,进程内转发不再被点名拒绝,而是带一张服务端为这次转发单独签发的转发凭据。凭据绑定这一次的方法、路由和动作运行,第一次出示即作废,目标应答即撤销,30 秒上限,只在进程内本次调用链里能兑换。目标端把它解析成同一个 agent(scope 上限、代表的用户、客户端),身份路由处 better-auth 只看到委托用户的请求级视图,不建任何会话。 - 为什么改:ADR-0011 已把「改我的资料」和需确认的管理员身份动作开放给 agent,但今天 agent 一个都跑不通(#28178 表把这两格点名拒绝)。Tesla 已裁决按 ADR 修正案实现(记录 6545950368),第一步 ADR 已合入(#28455)。 - 风险与回滚:只翻两格,OAuth agent × 身份路由、OAuth agent × 平台路由。后者要求 `actions:execute` + `data:write`,元数据门的写操作仍按名拒绝(403)。API key × 身份路由按裁决 6548923676 继续拒绝。目标端签出的会话 cookie 或轮换 token 不会回到 agent。回滚即 revert 本 PR,无数据迁移。 - 先看:`packages/rest/src/forward-credential.ts`(凭据四条性质),`packages/runtime/src/action-execution.ts` 的 `forwardApiAction` 和 scope 族规则,`packages/plugins/plugin-auth/src/agent-forward-identity-view.ts`(身份路由的请求级视图)。 - 两分钟验证:看 CI 的 dogfood 分片,`mcp-oauth-api-action-forward.dogfood.test.ts` 里真实 OAuth agent 改自己资料成功、审计行同时记 `performed_by` 和 `on_behalf_of`、只读 agent 和元数据写被按名拒绝。 - 需要你做的:以 os-zhuang 身份审批(Tier H,改了 `docs/adr/**` 的两句话,席位会发起评审请求)。下方 Acceptance notes 里有两个开放问题请裁决。 ## What changes - **`packages/rest/src/forward-credential.ts` (new).** The one producer and redeemer of the credential. - `withForwardCredential` mints 256 random bits per forward, bound to the method, the route and the action run, and revokes it in `finally` when the forward answers. - `redeemForwardCredential` answers the agent's provenance only inside the forward's own async chain, spends it on the first presentation (accepted or refused), and refuses another method, another route or an expired one. - The handle names nothing; nothing is stored, logged or returned. - **`packages/runtime/src/action-execution.ts`.** - The two cells flip. `oauth` × `identity` and `oauth` × `platform` now forward as the agent. `api_key` × `identity` is unchanged and still refuses by name. - The scope-family rule for targets outside MCP (`apiForwardScopeFamilyRefusal`): `actions:execute` always, `data:write` for a state-changing method, and a state-changing metadata-door target refused. Each refuses `403` `PERMISSION_DENIED` by name, before the mint. `list_actions` marks the action from the same predicate. - For an agent the forward carries no caller identity header, only the credential, and relays no response header back. - **`packages/runtime/src/security/resolve-execution-context.ts`** and **`packages/rest/src/rest-server.ts`.** A redeemed credential is handed to the shared assembler as the `oauth` provenance, exactly as the MCP door hands a verified token's. The target builds the same agent principal (scope-derived permissions, `onBehalfOf`, `performedBy`, `oauthScopes`, the consent organization). Credential admission is not recorded for a forwarded request. An unredeemable credential is ignored. - **`packages/plugins/plugin-auth`.** - `agent-forward-identity-view.ts` (new) is a middleware on the auth base path plus a better-auth plugin. A redeemed credential becomes a request-scoped in-memory view of the delegating user: no `sys_session` row, no token, no cookie. A banned or missing user gets no view. Credential response headers are stripped for the agent caller. - `auth-actor-attribution.ts` and `objectql-adapter.ts`: identity writes made inside the view carry `performedBy`, so the audit row records `performed_by` and `on_behalf_of`. - `auth-plugin.ts` and `auth-manager-plugin-list.ts`: wiring. - **`docs/adr/0090-…md`.** The two sentences the amendment names are corrected: rule 4's "today the OAuth / MCP client door alone", and the 2026-09-16 note's "REST and stdio hand the assembler `oauth: undefined` by construction". - **`content/docs/ai/actions-as-tools.mdx`.** The `api` row states the new table. - **Tests.** - Unit: `forward-credential.test.ts` (9), `rest-forward-credential-agent.test.ts` (5), `resolve-execution-context.forward-credential.test.ts` (5), `agent-forward-identity-view.test.ts` (5), and a new #28367 describe in `api-action-forward.test.ts` (58 total in the file). - Dogfood, against a booted showcase stack with a real OAuth-connected agent: `mcp-oauth-api-action-forward.dogfood.test.ts` was rewritten (10), and `mcp-system-object-action-exposure.dogfood.test.ts` now serves the formerly refused identity pins, with a member-agent intersection pin added (12). - **Changeset.** `.changeset/28367-agent-forward-credential.md` (patch for rest, runtime and plugin-auth). ## Acceptance notes - **Open question 1: a response body.** The ADR bans relaying credential headers to an agent, and this PR strips them. It says nothing about a target that returns a credential in its response body, and this PR does not inspect bodies. Recommendation: a follow-up names it in the ADR and refuses agent forwards to any route known to answer with a credential. - **Open question 2: key minting through the data door.** An agent forward to the API-key object's data route is not refused by `apiKeyManagementCredentialRefusal`, which refuses only the `api_key` credential. The agent's own scope ceiling and the user's permissions still apply. Recommendation: refuse agent principals there too, as a follow-up on the key-management door, since that door is outside this PR's surface. - **The identity seam is a middleware, not `handleRequest`.** The amendment names `auth-manager-request-routing.ts#handleRequest`. The platform's raw identity mounts (for example `update-user`) are served without passing through `handleRequest`, so a seam there would miss them. The middleware runs ahead of every route under the auth base path, after the IP gate. - **The view's session is fresh.** Its `createdAt` is the request time, so a route that requires a fresh session accepts the agent's view. The user's own consent and better-auth's own checks still stand. Flagged for the contract review. - **The safe-method branch is unreachable today.** The action schema allows only POST, PATCH, PUT and DELETE, so every agent forward outside MCP needs `data:write`. The `data:read` branch is implemented as the rule states and unit-pinned. - **The forward has no request timeout.** The credential's 30-second bound covers a hanging target. The target's own timeout is unchanged. - **The REST action door is unreachable for an agent.** An OAuth access token is honoured on the MCP endpoint alone, so the live measure and the dogfood pins run through MCP `run_action`. The REST action door shares the table function and is pinned at unit level. - **Out of the claim's file surface:** `scripts/engine-double-contract.pinned.json` (regenerated with `--write` for the new test doubles), five `scripts/adr-anchors/*.json` entries for the new and changed files, `packages/rest/src/index.ts` (exports), and `packages/runtime/src/domains/actions.ts` (passes `recordId` into the forward binding). ⛔ No `packages/spec`, no `metadata-protocol/src/protocol.ts`. - **Left untouched on purpose:** the 2026-09-16 note's "produced at exactly one seam" wording. It is not one of the two named sentences. - **Live measure on `main` 52306d4 before the code:** both flipped cells refused by name, `api_key` × `identity` refused, `api_key` × `platform` served, and an outside OAuth token refused 401 at the data door and the identity mount (2 dogfood files, 16 of 16 pass). Kept at rule level, since this is a security card. - **Diff size:** 2027 changed lines (+1898 / -129) across 28 files, under the 3000 threshold. ## Verification Local, on `6bbf2eeb`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 families for the changed paths. Each ran with stdin from `/dev/null`, and its exit code was captured by redirect before any pipe. <details><summary>Gate readings</summary> - First pass: 108 of 116 lines exited 0. The reds and their fixes: - `check-plugin-teardown-shape --self-test`: the pinned commit was missing from the shallow clone; after `git fetch origin 621a487… --depth=1` it exits 0. - `check:engine-double-contract`: the new test doubles; ledger regenerated with `--write` and committed. - `check:objectql-double-limit`: both new doubles now honour `limit`. - `check:where-matcher`: the new matcher refuses `$` combinators and operators. - `check:test-source-alias`: a plugin-auth vitest alias was reverted; plugin-auth reads `@objectstack/rest` as its other tests do. - rest test typecheck (TS6133): an unused binding was removed. - Second pass, all 110 derived families: exit 0. - `dispatch-gates --ran`: `110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED`, exit 0. - Typecheck: `pnpm --filter <pkg> run typecheck` for `@objectstack/rest`, `@objectstack/runtime`, `@objectstack/plugin-auth` and `@objectstack/dogfood`, each exit 0. - `pnpm lint`, scoped to `packages/rest`, `packages/runtime`, `packages/plugins/plugin-auth` and `packages/qa/dogfood/test`: exit 0. - Sibling reader suites (outside the derivation): - `@objectstack/rest`: 6884 passed. - `@objectstack/mcp`: 603 passed. - `@objectstack/runtime`: 8055 passed. - `@objectstack/plugin-auth`: first run 2 failed (the two plugin-list pins in `auth-manager.test.ts`, which now name the new view plugin); after the fix, 3938 passed. - Dogfood: the two files, 22 of 22 passed. </details> <details><summary>Ablations (each pin red with the forbidden behaviour put back, then restored byte-identical; `git status` clean after each)</summary> Unit, via `scripts/ablation-replace.mjs`: | Ablated | Red | |:--|:--| | replay: first presentation does not spend | 2 failed | | revoke on answer removed | 1 failed | | expiry check removed | 1 failed | | method binding removed | 3 failed | | route binding removed | 2 failed | | out-of-process: a slot readable outside the chain | 1 failed | | `api_key` × `identity` accepted | 5 failed | | metadata-door refusal removed | 1 failed | | `data:write` requirement removed | 3 failed | | `actions:execute` requirement removed | 1 failed | | caller identity headers carried for an agent | 2 failed | | response headers relayed to an agent | 2 failed | | runtime: admission recorded for a forward | 1 failed | | REST: admission recorded for a forward | 1 failed | | view served for a banned user | 1 failed | | credential response headers not stripped | 1 failed | | caller identity headers not dropped at the mount | 1 failed | | an unredeemed credential header passed through | 1 failed | Dogfood, via `scripts/ablation-dist-preflight.mjs` on the built dist: | Ablated | Red | |:--|:--| | metadata-door refusal removed | 2 failed | | `data:write` requirement removed | 1 failed | | `api_key` × `identity` accepted | 1 failed | | the view answers the user as an admin | 2 failed (both in the exposure file) | </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf6c8c9 commit d3fdd46

28 files changed

Lines changed: 1902 additions & 129 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/plugin-auth': patch
5+
---
6+
7+
feat(runtime, rest, plugin-auth): an AI agent connected over OAuth can run `api` actions that forward to identity routes and other same-origin routes, as the agent
8+
9+
Clause-②: yes (widening, at the two named cells only)
10+
Metadata-protocol: none
11+
12+
An `api` action runs by forwarding a request to its `target` inside the server. Until now an agent connected over OAuth was refused by name before that forward unless the target was the MCP endpoint, because its access token is honoured there alone. So the identity actions ADR-0011 exposes to agents (update my profile, the confirmed admin actions) never ran for one.
13+
14+
Now, per ADR-0090 D10 rule 5, the forward carries a credential the server mints for that one forward instead of the agent's token. It is bound to the forward's method and route and to the action run, spent by its first use, revoked when the target answers, and redeemable only inside the server's own in-process call. At the target it resolves to the same agent: its scope ceiling, the user it acts for and its client. At the identity routes, the auth library is shown the delegating user for that one request only; no session is created, and the user's own permissions there still decide. Writes the agent makes are recorded with both the agent and its user.
15+
16+
What changes, at exactly two cells of the forward table:
17+
18+
- an agent at an identity route: refused by name before, now forwarded as the agent;
19+
- an agent at any other same-origin route: refused by name before, now forwarded as the agent when the connection holds `actions:execute` and `data:write`.
20+
21+
What does not change:
22+
23+
- An API key is still refused by name at an identity route.
24+
- A state-changing request to the metadata endpoint is never forwarded for an agent; it is refused `403` by name.
25+
- An agent without `data:write` is refused `403` by name, and `list_actions` marks those actions the same way.
26+
- Nothing the target mints, such as a rotated session token or cookie, comes back to an agent.
27+
- An OAuth access token presented directly to the data API or to an identity route is still refused.

‎content/docs/ai/actions-as-tools.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ through the metadata API.
113113
|:---|:---|:---|
114114
| `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | open (MCP) + ObjectOS |
115115
| `flow` | automation flow runner — `execute(target, automationContext)` (caller identity forwarded so `runAs` engages) | open (MCP) + ObjectOS; needs the `automation` service registered |
116-
| `api` | same-origin `target` (a path starting with `/`): forwarded in-process through the action door to that target, after the door's gates, with the caller's own credentials; a credential rotation the target emits comes back as the bearer-lane headers only (`set-auth-token` / `set-admin-session-token`), never a `Set-Cookie` line. The forward carries only a credential the target's door accepts: an identity route (`/api/v1/auth/…`) reads a signed-in session only, every other target a session or an API key, and an OAuth access token is honoured on the MCP endpoint only. Any other caller is refused `401` `UNAUTHENTICATED` before the forward, with a sentence naming the action, its target and what would let it run: an API key on an identity route, and an agent connected over OAuth on any target outside the MCP endpoint | open (MCP) + ObjectOS; an `api` action whose `target` is on another origin stays browser-only and is not listed |
116+
| `api` | same-origin `target` (a path starting with `/`): forwarded in-process through the action door to that target, after the door's gates, with the caller's own credentials; a credential rotation the target emits comes back as the bearer-lane headers only (`set-auth-token` / `set-admin-session-token`), never a `Set-Cookie` line. The forward carries only a credential the target's door accepts: an identity route (`/api/v1/auth/…`) reads a signed-in session only, every other target a session or an API key, and an OAuth access token is honoured on the MCP endpoint only. An API key on an identity route is refused `401` `UNAUTHENTICATED` before the forward, with a sentence naming the action, its target and what would let it run. An agent connected over OAuth is forwarded with a credential the server mints for that one forward instead of its token, and the target reads the same agent (its scope ceiling, the user it acts for, its client). Outside the MCP endpoint that needs the `actions:execute` and `data:write` scopes, since every `api` action's method changes state, and a state-changing request to the metadata endpoint (`/api/v1/meta/…`) is never forwarded for an agent: those are refused `403` `PERMISSION_DENIED` by name, before the forward. Nothing the target mints, such as a rotated session, comes back to an agent | open (MCP) + ObjectOS; an `api` action whose `target` is on another origin stays browser-only and is not listed |
117117

118118
Console-only types (`url`, `modal`, `form`) are always skipped.
119119

‎docs/adr/0090-permission-model-v2-concept-convergence.md‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -418,8 +418,10 @@ depends on:
418418
agent may *initiate*, not what it may *complete alone*.
419419
4. **Dual attribution**: every write **by an agent principal** records `performed_by` (agent) +
420420
`on_behalf_of` (user) + run id; explain (D6) reports both sides of the intersection. An
421-
agent principal is a caller a door resolves to `principalKind: 'agent'` — today the OAuth /
422-
MCP client door alone. A caller of any other kind records the principal it authenticates as,
421+
agent principal is a caller a door resolves to `principalKind: 'agent'`: the OAuth / MCP
422+
client door, and (since #28367) a door serving an `api` action's forward that carries the
423+
forward credential of rule 5, which is minted only for an agent that first door admitted.
424+
A caller of any other kind records the principal it authenticates as,
423425
and the missing `performed_by` is itself the record that the principal acted for itself; for
424426
the API-key case see the 2026-09-16 note at the end of this decision.
425427
5. **A forward carries the agent, never the user** (amended 2026-10-31): when an agent runs an
@@ -454,10 +456,11 @@ alias tax on every API.
454456
> on **every** door that runs
455457
> `packages/core/src/security/resolve-authz-context.ts#resolveAuthzContext` — REST, the runtime /
456458
> MCP **HTTP** dispatcher, and the MCP **stdio** transport — and none of the three turns one into
457-
> an agent, by two different mechanisms. REST and stdio hand the assembler
458-
> `oauth: undefined` **by construction** (`packages/rest/src/rest-server.ts`,
459-
> `packages/mcp/src/plugin.ts#resolveStdioExecutionContext`), so agent provenance is not
460-
> representable on either. On the HTTP dispatcher — the one door that does mint agent principals —
459+
> an agent, by two different mechanisms. stdio hands the assembler `oauth: undefined` **by
460+
> construction** (`packages/mcp/src/plugin.ts#resolveStdioExecutionContext`), so agent provenance
461+
> is not representable there; REST did the same until #28367, and since then represents it for
462+
> one caller only, a request carrying a rule 5 forward credential
463+
> (`packages/rest/src/rest-server.ts`), never for a key. On the HTTP dispatcher — the one door that does mint agent principals —
461464
> it is a **guard** rather than a structural impossibility:
462465
> `packages/runtime/src/security/resolve-execution-context.ts#extractJwtBearer` refuses an
463466
> `osk_`-prefixed bearer and anything that is not a three-segment JWS, so no OAuth provenance is
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// [ADR-0090 D10 rule 5, #28367] The identity mount's request-scoped view of
4+
// the delegating user. Pinned here at the seam (the mount middleware and the
5+
// session-lookup plugin); end to end through better-auth in
6+
// `packages/qa/dogfood/test/mcp-oauth-api-action-forward.dogfood.test.ts`.
7+
8+
import { describe, it, expect, vi } from 'vitest';
9+
import { FORWARD_CREDENTIAL_HEADER, withForwardCredential } from '@objectstack/rest';
10+
import {
11+
agentForwardIdentityViewPlugin,
12+
agentForwardPerformedBy,
13+
serveAgentForwardAtIdentityMount,
14+
} from './agent-forward-identity-view.js';
15+
import { authSystemWriteContext } from './auth-actor-attribution.js';
16+
17+
const AGENT = { userId: 'u_mia', clientId: 'agent-client-1', scopes: ['actions:execute', 'data:write'], organizationId: 'org_alpha' };
18+
const URL_ = 'http://localhost/api/v1/auth/update-user';
19+
const bind = { method: 'POST', path: '/api/v1/auth/update-user', run: { objectName: 'sys_user', actionName: 'update_my_profile' }, agent: AGENT };
20+
21+
/** A Hono-shaped context: `c.req.raw` and a writable `c.res`. */
22+
const honoCtx = (headers: Record<string, string>) => ({
23+
req: { raw: new Request(URL_, { method: 'POST', headers, body: JSON.stringify({ name: 'x' }) }) },
24+
res: undefined as Response | undefined,
25+
});
26+
27+
/** The plugin's wrapped adapter over an inner one that knows one user and no session. */
28+
const wrappedAdapter = (user: Record<string, unknown> | null = { id: 'u_mia', email: 'mia@example.com', banned: false }) => {
29+
const inner = {
30+
findOne: vi.fn(async (args: any) => (args.model === 'user' ? user : null)),
31+
create: vi.fn(),
32+
};
33+
const out: any = agentForwardIdentityViewPlugin().init({ adapter: inner, sessionConfig: { expiresIn: 604800 } });
34+
return { inner, adapter: out.context.adapter };
35+
};
36+
37+
describe('[ADR-0090 D10 rule 5] the identity mount: a forward credential becomes a request-scoped view of the delegating user', () => {
38+
it('a redeemed credential: better-auth reads one opaque bearer and none of the caller\'s identity headers; its lookup answers the delegating user in the consent\'s organization', async () => {
39+
const { adapter, inner } = wrappedAdapter();
40+
let seen: { authorization: string | null; cookie: string | null; forward: string | null; apiKey: string | null } | undefined;
41+
let session: any;
42+
let performedBy: unknown;
43+
let writeCtx: unknown;
44+
45+
await withForwardCredential(bind, async (handle) => {
46+
const c = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: handle, cookie: 'better-auth.session_token=ambient', 'x-api-key': 'osk_x' });
47+
await serveAgentForwardAtIdentityMount(c, async () => {
48+
const h = c.req.raw.headers;
49+
seen = { authorization: h.get('authorization'), cookie: h.get('cookie'), forward: h.get(FORWARD_CREDENTIAL_HEADER), apiKey: h.get('x-api-key') };
50+
const token = String(seen.authorization).replace(/^Bearer /, '');
51+
session = await adapter.findOne({ model: 'session', where: [{ field: 'token', value: token }], join: { user: true } });
52+
performedBy = agentForwardPerformedBy();
53+
writeCtx = await authSystemWriteContext();
54+
c.res = new Response('{}', { headers: { 'set-cookie': 'better-auth.session_token=minted', 'set-auth-token': 'minted', 'content-type': 'application/json' } });
55+
});
56+
// Nothing that carries a credential leaves the mount for an agent caller.
57+
expect(c.res!.headers.get('set-cookie')).toBeNull();
58+
expect(c.res!.headers.get('set-auth-token')).toBeNull();
59+
expect(c.res!.headers.get('content-type')).toBe('application/json');
60+
});
61+
62+
expect(seen!.authorization).toMatch(/^Bearer [A-Za-z0-9_-]{32}$/);
63+
expect(seen!.cookie).toBeNull();
64+
expect(seen!.apiKey).toBeNull();
65+
expect(seen!.forward).toBeNull();
66+
expect(session).toMatchObject({ userId: 'u_mia', activeOrganizationId: 'org_alpha', user: { id: 'u_mia' } });
67+
expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now());
68+
// No session of record: the view never wrote one.
69+
expect(inner.create).not.toHaveBeenCalled();
70+
expect(performedBy).toEqual({ clientId: 'agent-client-1' });
71+
expect(writeCtx).toMatchObject({ isSystem: true, performedBy: { clientId: 'agent-client-1' } });
72+
});
73+
74+
it('the view\'s bearer is no session anywhere else: outside the request\'s own chain the lookup passes through and finds nothing', async () => {
75+
const { adapter, inner } = wrappedAdapter();
76+
let token = '';
77+
await withForwardCredential(bind, async (handle) => {
78+
const c = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: handle });
79+
await serveAgentForwardAtIdentityMount(c, async () => {
80+
token = String(c.req.raw.headers.get('authorization')).replace(/^Bearer /, '');
81+
});
82+
});
83+
expect(await adapter.findOne({ model: 'session', where: [{ field: 'token', value: token }], join: { user: true } })).toBeNull();
84+
expect(inner.findOne).toHaveBeenCalledWith(expect.objectContaining({ model: 'session' }));
85+
expect(agentForwardPerformedBy()).toBeUndefined();
86+
});
87+
88+
it('a banned or missing delegating user has no view', async () => {
89+
for (const user of [{ id: 'u_mia', banned: true, banExpires: null }, null]) {
90+
const { adapter } = wrappedAdapter(user);
91+
await withForwardCredential(bind, async (handle) => {
92+
const c = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: handle });
93+
await serveAgentForwardAtIdentityMount(c, async () => {
94+
const token = String(c.req.raw.headers.get('authorization')).replace(/^Bearer /, '');
95+
expect(await adapter.findOne({ model: 'session', where: [{ field: 'token', value: token }], join: { user: true } })).toBeNull();
96+
});
97+
});
98+
}
99+
});
100+
101+
it('a credential that does not redeem (from outside, or for another route) is dropped and the request resolves on its other headers alone', async () => {
102+
const outside = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: 'A'.repeat(43), cookie: 'c=1' });
103+
await serveAgentForwardAtIdentityMount(outside, async () => {
104+
expect(outside.req.raw.headers.get(FORWARD_CREDENTIAL_HEADER)).toBeNull();
105+
expect(outside.req.raw.headers.get('authorization')).toBeNull();
106+
expect(outside.req.raw.headers.get('cookie')).toBe('c=1');
107+
expect(agentForwardPerformedBy()).toBeUndefined();
108+
});
109+
110+
await withForwardCredential({ ...bind, path: '/api/v1/auth/admin/ban-user' }, async (handle) => {
111+
const c = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: handle });
112+
await serveAgentForwardAtIdentityMount(c, async () => {
113+
expect(c.req.raw.headers.get('authorization')).toBeNull();
114+
expect(agentForwardPerformedBy()).toBeUndefined();
115+
});
116+
});
117+
});
118+
119+
it('no credential presented: the request passes through untouched', async () => {
120+
const c = honoCtx({ authorization: 'Bearer person_session' });
121+
const before = c.req.raw;
122+
await serveAgentForwardAtIdentityMount(c, async () => undefined);
123+
expect(c.req.raw).toBe(before);
124+
});
125+
});

0 commit comments

Comments
 (0)