Skip to content

Commit 9019ded

Browse files
khaliqgantProactive Runtime Bot
andauthored
feat(tokens): implement path-scoped relayfile tokens (#42)
* feat(tokens): implement path-scoped relayfile tokens * fix(auth): guard empty bearer workspace token --------- Co-authored-by: Proactive Runtime Bot <agent@agent-relay.com>
1 parent 2c560d0 commit 9019ded

10 files changed

Lines changed: 479 additions & 56 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ type PathTokenIssueRequest = {
5757

5858
- `POST /v1/tokens/workspace` returns a long-lived `relay_ws_*` workspace token.
5959
- `POST /v1/tokens/agent` accepts that workspace token via `x-api-key` and returns a short-lived `relay_ag_*` token pair for one `agentId`.
60-
- `POST /v1/tokens/path` accepts that same workspace token via `x-api-key` and returns a short-lived `relay_pa_*` token pair whose `relayfile:fs:*` scopes are intersected with the requested `paths`.
60+
- `POST /v1/tokens/path` accepts that same workspace token via `x-api-key` or `Authorization: Bearer relay_ws_*` and returns a short-lived `relay_pa_*` token pair whose `relayfile:fs:*` scopes are intersected with the requested `paths`.
6161
- `POST /v1/tokens/refresh` rotates the current pair and preserves the agent-token lineage. Revoking the parent workspace token invalidates all derived agent tokens.
6262

6363
`paths` uses the same filesystem constraint model as `relayfile:fs:*` scopes: exact paths or trailing-prefix globs such as `/linear/issues/*`. For compatibility, `/linear/issues/**` is normalized to `/linear/issues/*` during issuance.

packages/sdk/typescript/src/__tests__/client-tokens.test.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { test } from "node:test";
33
import type {
44
AgentTokenPair,
55
AgentTokenIssueRequest,
6+
PathTokenPair,
67
PathTokenIssueRequest,
78
RelayAuthTokenClaims,
89
TokenPair,
@@ -39,12 +40,15 @@ type TokenClient = RelayAuthClient & {
3940
expiresIn?: number;
4041
}): Promise<AgentTokenPair>;
4142
issuePathToken(options: {
42-
agentId: string;
43+
agentId?: string;
44+
agentName?: string;
45+
workspaceId?: string;
4346
paths: string[];
4447
scopes?: string[];
4548
audience?: string[];
4649
expiresIn?: number;
47-
}): Promise<never>;
50+
ttlSeconds?: number;
51+
}): Promise<PathTokenPair>;
4852
revokeToken(tokenId: string): Promise<void>;
4953
introspectToken(token: string): Promise<RelayAuthTokenClaims | null>;
5054
};
@@ -115,6 +119,18 @@ const agentTokenPair: AgentTokenPair = {
115119
issuedViaWorkspaceTokenId: "ak_workspace_123",
116120
};
117121

122+
const pathTokenPair: PathTokenPair = {
123+
...tokenPair,
124+
accessToken: "relay_pa_access.token.value",
125+
refreshToken: "relay_pa_refresh.token.value",
126+
agentId: "agent_123",
127+
agentName: "cloud-orchestrator",
128+
workspaceId: "ws_123",
129+
tokenClass: "relay_pa",
130+
paths: ["/linear/issues/*"],
131+
issuedViaWorkspaceTokenId: "ak_workspace_123",
132+
};
133+
118134
const rotatedAgentTokenPair: TokenPair = {
119135
...tokenPair,
120136
accessToken: "relay_ag_rotated.access.token",
@@ -293,35 +309,22 @@ test("issueAgentToken uses x-api-key and posts the agent exchange request", asyn
293309
});
294310
});
295311

296-
test("issuePathToken sends the future path-scoped request shape and surfaces the M1 501 stub", async (t) => {
312+
test("issuePathToken posts the path-scoped request shape", async (t) => {
297313
const client = new RelayAuthClient({ baseUrl, apiKey: workspaceTokenResponse.key }) as TokenClient;
298314
const requestBody: PathTokenIssueRequest = {
299-
agentId: "agent_123",
315+
workspaceId: "ws_123",
316+
agentName: "cloud-orchestrator",
300317
paths: ["/linear/issues/**", "/github/repos/acme/api/**"],
301318
scopes: ["relayfile:fs:read:/linear/issues/**"],
302319
audience: ["relayfile"],
303-
expiresIn: 1800,
320+
ttlSeconds: 1800,
304321
};
305-
const fetchMock = mockFetch(() =>
306-
jsonResponse(
307-
{
308-
error: "path_scoped_tokens_not_implemented",
309-
code: "not_implemented",
310-
},
311-
501,
312-
));
322+
const fetchMock = mockFetch(() => jsonResponse(pathTokenPair, 201));
313323
t.after(() => fetchMock.restore());
314324

315-
await assert.rejects(
316-
client.issuePathToken(requestBody),
317-
(error: unknown) => {
318-
assert.ok(error instanceof RelayAuthError);
319-
assert.equal(error.code, "not_implemented");
320-
assert.equal(error.statusCode, 501);
321-
return true;
322-
},
323-
);
325+
const result = await client.issuePathToken(requestBody);
324326

327+
assert.deepEqual(result, pathTokenPair);
325328
const request = await inspectCall(fetchMock.calls[0]);
326329
assert.equal(request.url.toString(), `${baseUrl}/v1/tokens/path`);
327330
assert.equal(request.method, "POST");

packages/sdk/typescript/src/client.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
AuditQuery,
77
CreateIdentityInput,
88
IdentityStatus,
9+
PathTokenPair,
910
PathTokenIssueRequest,
1011
RelayAuthTokenClaims,
1112
Role,
@@ -74,6 +75,7 @@ export class RelayAuthClient {
7475
auditEntry: AuditEntry;
7576
workspaceTokenIssueResponse: WorkspaceTokenIssueResponse;
7677
agentTokenPair: AgentTokenPair;
78+
pathTokenPair: PathTokenPair;
7779
};
7880

7981
readonly options: RelayAuthClientOptions;
@@ -140,8 +142,8 @@ export class RelayAuthClient {
140142
});
141143
}
142144

143-
async issuePathToken(options: PathTokenIssueRequest): Promise<never> {
144-
return this._request<never>("/v1/tokens/path", {
145+
async issuePathToken(options: PathTokenIssueRequest): Promise<PathTokenPair> {
146+
return this._request<PathTokenPair>("/v1/tokens/path", {
145147
method: "POST",
146148
body: options,
147149
headers: this.options.apiKey

packages/sdk/typescript/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export type {
1111
CreateIdentityInput,
1212
IdentityStatus,
1313
IdentityType,
14+
PathTokenPair,
1415
PathTokenIssueRequest,
15-
PathTokenStubResponse,
1616
RelayAuthTokenClaims,
1717
Role,
1818
TokenPair,

packages/server/src/__tests__/api-key-auth-middleware.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,34 @@ test("POST /v1/tokens with x-api-key succeeds against a Workers-style locked-hea
189189
assert.equal(typeof tokens.refreshToken, "string");
190190
});
191191

192+
test("Authorization: Bearer without token does not crash apiKeyAuth-mounted routes", async () => {
193+
const app = createTestApp();
194+
195+
const response = await app.request(
196+
createTestRequest(
197+
"POST",
198+
"/v1/tokens/path",
199+
{
200+
paths: ["/linear/issues/**"],
201+
},
202+
{
203+
Authorization: "Bearer",
204+
},
205+
),
206+
undefined,
207+
app.bindings,
208+
);
209+
210+
assert.notEqual(
211+
response.status,
212+
500,
213+
"apiKeyAuth must not throw when Authorization is Bearer without a token",
214+
);
215+
await assertJsonResponse<{ code?: string }>(response, 401, (body) => {
216+
assert.equal(body.code, "invalid_authorization");
217+
});
218+
});
219+
192220
test("bearer-wins precedence: a valid bearer takes over even when x-api-key is also present", async () => {
193221
const app = createTestApp();
194222
const created = await mintApiKey(app, ["relayauth:identity:read:*"]);

packages/server/src/__tests__/tokens-route.test.ts

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import test from "node:test";
44

55
import type {
66
AgentTokenPair,
7+
PathTokenPair,
78
RelayAuthTokenClaims,
89
TokenPair,
910
WorkspaceTokenIssueResponse,
@@ -53,7 +54,11 @@ function signRs256Jwt(claims: RelayAuthTokenClaims): string {
5354
}
5455

5556
function decodeJwtJsonSegment<T>(token: string, index: 0 | 1): T {
56-
const normalized = token.startsWith("relay_ag_") ? token.slice("relay_ag_".length) : token;
57+
const normalized = token.startsWith("relay_ag_")
58+
? token.slice("relay_ag_".length)
59+
: token.startsWith("relay_pa_")
60+
? token.slice("relay_pa_".length)
61+
: token;
5762
const segments = normalized.split(".");
5863
assert.equal(segments.length, 3, "expected a compact JWT with exactly three segments");
5964
return JSON.parse(Buffer.from(segments[index], "base64url").toString("utf8")) as T;
@@ -554,14 +559,75 @@ test("POST /v1/tokens/agent", async (t) => {
554559
});
555560

556561
test("POST /v1/tokens/path", async (t) => {
557-
await t.test("returns the M1 not-implemented stub response", async () => {
562+
await t.test("mints a relay_pa token pair from a workspace token", async () => {
558563
const { app, authHeaders } = await createHarness({
559564
authClaims: {
560-
scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*"],
565+
scopes: [
566+
"relayauth:api-key:manage:*",
567+
"relayauth:token:create:*",
568+
"relayfile:fs:read:*",
569+
"relayfile:fs:write:*",
570+
],
571+
},
572+
});
573+
const workspaceToken = await issueWorkspaceToken(app, authHeaders, {
574+
scopes: ["relayauth:token:create:*", "relayfile:fs:read:*", "relayfile:fs:write:*"],
575+
});
576+
577+
const response = await requestRoute(app, "POST", "/v1/tokens/path", {
578+
body: {
579+
workspaceId: "ws_tokens_route",
580+
agentName: "cloud-orchestrator",
581+
paths: ["/linear/issues/**"],
582+
ttlSeconds: 7200,
583+
},
584+
headers: {
585+
Authorization: `Bearer ${workspaceToken.key}`,
586+
},
587+
});
588+
589+
const body = await assertJsonResponse<PathTokenPair>(response, 201);
590+
assert.equal(body.agentId, "agent_cloud-orchestrator");
591+
assert.equal(body.agentName, "cloud-orchestrator");
592+
assert.equal(body.workspaceId, "ws_tokens_route");
593+
assert.equal(body.tokenClass, "relay_pa");
594+
assert.deepEqual(body.paths, ["/linear/issues/*"]);
595+
assert.match(body.accessToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
596+
assert.match(body.refreshToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
597+
598+
const accessClaims = decodeJwtJsonSegment<RelayAuthTokenClaims>(body.accessToken, 1);
599+
assert.equal(accessClaims.sub, "agent_cloud-orchestrator");
600+
assert.equal(accessClaims.meta?.tokenClass, "path");
601+
assert.equal(accessClaims.meta?.workspaceTokenId, workspaceToken.workspaceToken.id);
602+
assert.equal(accessClaims.meta?.agentName, "cloud-orchestrator");
603+
assert.deepEqual(JSON.parse(accessClaims.meta?.paths ?? "[]"), ["/linear/issues/*"]);
604+
assert.deepEqual(accessClaims.scopes, [
605+
"relayfile:fs:read:/linear/issues/*",
606+
"relayfile:fs:write:/linear/issues/*",
607+
]);
608+
assert.deepEqual(accessClaims.aud, ["relayfile"]);
609+
assert.ok(accessClaims.exp - accessClaims.iat <= 3600, "path access TTL should cap at 1h");
610+
611+
const refreshResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", {
612+
body: {
613+
refreshToken: body.refreshToken,
614+
},
615+
});
616+
const refreshed = await assertJsonResponse<TokenPair>(refreshResponse, 200);
617+
assert.match(refreshed.accessToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
618+
const refreshedClaims = decodeJwtJsonSegment<RelayAuthTokenClaims>(refreshed.accessToken, 1);
619+
assert.equal(refreshedClaims.meta?.tokenClass, "path");
620+
assert.deepEqual(refreshedClaims.scopes, accessClaims.scopes);
621+
});
622+
623+
await t.test("rejects path scopes outside the workspace token grant", async () => {
624+
const { app, authHeaders } = await createHarness({
625+
authClaims: {
626+
scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*", "relayfile:fs:read:*"],
561627
},
562628
});
563629
const workspaceToken = await issueWorkspaceToken(app, authHeaders, {
564-
scopes: ["relayauth:token:create:*"],
630+
scopes: ["relayauth:token:create:*", "relayfile:fs:read:*"],
565631
});
566632

567633
const response = await requestRoute(app, "POST", "/v1/tokens/path", {
@@ -574,9 +640,8 @@ test("POST /v1/tokens/path", async (t) => {
574640
},
575641
});
576642

577-
await assertJsonResponse<ErrorBody>(response, 501, (body) => {
578-
assert.equal(body.error, "path_scoped_tokens_not_implemented");
579-
assert.equal(body.code, "not_implemented");
643+
await assertJsonResponse<ErrorBody>(response, 403, (body) => {
644+
assert.equal(body.code, "insufficient_scope");
580645
});
581646
});
582647
});

packages/server/src/lib/jwt.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
export const RELAY_AGENT_TOKEN_PREFIX = "relay_ag_";
2+
export const RELAY_PATH_TOKEN_PREFIX = "relay_pa_";
3+
const RELAY_TOKEN_PREFIXES = [RELAY_AGENT_TOKEN_PREFIX, RELAY_PATH_TOKEN_PREFIX] as const;
4+
export type RelayTokenPrefix = typeof RELAY_TOKEN_PREFIXES[number];
25

36
export function decodeBase64Url(value: string): string {
47
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
@@ -27,10 +30,18 @@ export function wrapAgentToken(token: string): string {
2730
return `${RELAY_AGENT_TOKEN_PREFIX}${token}`;
2831
}
2932

33+
export function wrapRelayToken(token: string, prefix: RelayTokenPrefix): string {
34+
return `${prefix}${token}`;
35+
}
36+
3037
export function unwrapRelayToken(token: string): string {
31-
return token.startsWith(RELAY_AGENT_TOKEN_PREFIX)
32-
? token.slice(RELAY_AGENT_TOKEN_PREFIX.length)
33-
: token;
38+
for (const prefix of RELAY_TOKEN_PREFIXES) {
39+
if (token.startsWith(prefix)) {
40+
return token.slice(prefix.length);
41+
}
42+
}
43+
44+
return token;
3445
}
3546

3647
export function splitJwtSegments(token: string): [string, string, string] | null {

packages/server/src/middleware/api-key-auth.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MiddlewareHandler } from "hono";
22
import type { AppEnv } from "../env.js";
3+
import { WORKSPACE_TOKEN_PREFIX } from "../lib/api-keys.js";
34
import { authenticateBearerOrApiKey } from "../lib/auth.js";
45

56
/**
@@ -18,13 +19,14 @@ import { authenticateBearerOrApiKey } from "../lib/auth.js";
1819
*/
1920
export function apiKeyAuth(): MiddlewareHandler<AppEnv> {
2021
return async (c, next) => {
21-
const apiKey = c.req.header("x-api-key");
22+
const authorization = c.req.header("authorization");
23+
const apiKey = c.req.header("x-api-key") ?? extractBearerWorkspaceToken(authorization);
2224
if (!apiKey) {
2325
return next();
2426
}
2527

2628
const auth = await authenticateBearerOrApiKey(
27-
c.req.header("authorization"),
29+
authorization,
2830
apiKey,
2931
c.env,
3032
c.get("storage"),
@@ -41,3 +43,16 @@ export function apiKeyAuth(): MiddlewareHandler<AppEnv> {
4143
await next();
4244
};
4345
}
46+
47+
function extractBearerWorkspaceToken(authorization: string | undefined): string | undefined {
48+
if (!authorization) {
49+
return undefined;
50+
}
51+
52+
const [scheme, token] = authorization.split(/\s+/, 2);
53+
if (scheme !== "Bearer" || !token || !token.startsWith(WORKSPACE_TOKEN_PREFIX)) {
54+
return undefined;
55+
}
56+
57+
return token;
58+
}

0 commit comments

Comments
 (0)