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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type PathTokenIssueRequest = {

- `POST /v1/tokens/workspace` returns a long-lived `relay_ws_*` workspace token.
- `POST /v1/tokens/agent` accepts that workspace token via `x-api-key` and returns a short-lived `relay_ag_*` token pair for one `agentId`.
- `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`.
- `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`.
- `POST /v1/tokens/refresh` rotates the current pair and preserves the agent-token lineage. Revoking the parent workspace token invalidates all derived agent tokens.

`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.
Expand Down
47 changes: 25 additions & 22 deletions packages/sdk/typescript/src/__tests__/client-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from "node:test";
import type {
AgentTokenPair,
AgentTokenIssueRequest,
PathTokenPair,
PathTokenIssueRequest,
RelayAuthTokenClaims,
TokenPair,
Expand Down Expand Up @@ -39,12 +40,15 @@ type TokenClient = RelayAuthClient & {
expiresIn?: number;
}): Promise<AgentTokenPair>;
issuePathToken(options: {
agentId: string;
agentId?: string;
agentName?: string;
workspaceId?: string;
paths: string[];
scopes?: string[];
audience?: string[];
expiresIn?: number;
}): Promise<never>;
ttlSeconds?: number;
}): Promise<PathTokenPair>;
revokeToken(tokenId: string): Promise<void>;
introspectToken(token: string): Promise<RelayAuthTokenClaims | null>;
};
Expand Down Expand Up @@ -115,6 +119,18 @@ const agentTokenPair: AgentTokenPair = {
issuedViaWorkspaceTokenId: "ak_workspace_123",
};

const pathTokenPair: PathTokenPair = {
...tokenPair,
accessToken: "relay_pa_access.token.value",
refreshToken: "relay_pa_refresh.token.value",
agentId: "agent_123",
agentName: "cloud-orchestrator",
workspaceId: "ws_123",
tokenClass: "relay_pa",
paths: ["/linear/issues/*"],
issuedViaWorkspaceTokenId: "ak_workspace_123",
};

const rotatedAgentTokenPair: TokenPair = {
...tokenPair,
accessToken: "relay_ag_rotated.access.token",
Expand Down Expand Up @@ -293,35 +309,22 @@ test("issueAgentToken uses x-api-key and posts the agent exchange request", asyn
});
});

test("issuePathToken sends the future path-scoped request shape and surfaces the M1 501 stub", async (t) => {
test("issuePathToken posts the path-scoped request shape", async (t) => {
const client = new RelayAuthClient({ baseUrl, apiKey: workspaceTokenResponse.key }) as TokenClient;
const requestBody: PathTokenIssueRequest = {
agentId: "agent_123",
workspaceId: "ws_123",
agentName: "cloud-orchestrator",
paths: ["/linear/issues/**", "/github/repos/acme/api/**"],
scopes: ["relayfile:fs:read:/linear/issues/**"],
audience: ["relayfile"],
expiresIn: 1800,
ttlSeconds: 1800,
};
const fetchMock = mockFetch(() =>
jsonResponse(
{
error: "path_scoped_tokens_not_implemented",
code: "not_implemented",
},
501,
));
const fetchMock = mockFetch(() => jsonResponse(pathTokenPair, 201));
t.after(() => fetchMock.restore());

await assert.rejects(
client.issuePathToken(requestBody),
(error: unknown) => {
assert.ok(error instanceof RelayAuthError);
assert.equal(error.code, "not_implemented");
assert.equal(error.statusCode, 501);
return true;
},
);
const result = await client.issuePathToken(requestBody);

assert.deepEqual(result, pathTokenPair);
const request = await inspectCall(fetchMock.calls[0]);
assert.equal(request.url.toString(), `${baseUrl}/v1/tokens/path`);
assert.equal(request.method, "POST");
Expand Down
6 changes: 4 additions & 2 deletions packages/sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
AuditQuery,
CreateIdentityInput,
IdentityStatus,
PathTokenPair,
PathTokenIssueRequest,
RelayAuthTokenClaims,
Role,
Expand Down Expand Up @@ -74,6 +75,7 @@ export class RelayAuthClient {
auditEntry: AuditEntry;
workspaceTokenIssueResponse: WorkspaceTokenIssueResponse;
agentTokenPair: AgentTokenPair;
pathTokenPair: PathTokenPair;
};

readonly options: RelayAuthClientOptions;
Expand Down Expand Up @@ -140,8 +142,8 @@ export class RelayAuthClient {
});
}

async issuePathToken(options: PathTokenIssueRequest): Promise<never> {
return this._request<never>("/v1/tokens/path", {
async issuePathToken(options: PathTokenIssueRequest): Promise<PathTokenPair> {
return this._request<PathTokenPair>("/v1/tokens/path", {
method: "POST",
body: options,
headers: this.options.apiKey
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export type {
CreateIdentityInput,
IdentityStatus,
IdentityType,
PathTokenPair,
PathTokenIssueRequest,
PathTokenStubResponse,
RelayAuthTokenClaims,
Role,
TokenPair,
Expand Down
28 changes: 28 additions & 0 deletions packages/server/src/__tests__/api-key-auth-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,34 @@ test("POST /v1/tokens with x-api-key succeeds against a Workers-style locked-hea
assert.equal(typeof tokens.refreshToken, "string");
});

test("Authorization: Bearer without token does not crash apiKeyAuth-mounted routes", async () => {
const app = createTestApp();

const response = await app.request(
createTestRequest(
"POST",
"/v1/tokens/path",
{
paths: ["/linear/issues/**"],
},
{
Authorization: "Bearer",
},
),
undefined,
app.bindings,
);

assert.notEqual(
response.status,
500,
"apiKeyAuth must not throw when Authorization is Bearer without a token",
);
await assertJsonResponse<{ code?: string }>(response, 401, (body) => {
assert.equal(body.code, "invalid_authorization");
});
});

test("bearer-wins precedence: a valid bearer takes over even when x-api-key is also present", async () => {
const app = createTestApp();
const created = await mintApiKey(app, ["relayauth:identity:read:*"]);
Expand Down
79 changes: 72 additions & 7 deletions packages/server/src/__tests__/tokens-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";

import type {
AgentTokenPair,
PathTokenPair,
RelayAuthTokenClaims,
TokenPair,
WorkspaceTokenIssueResponse,
Expand Down Expand Up @@ -53,7 +54,11 @@ function signRs256Jwt(claims: RelayAuthTokenClaims): string {
}

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

test("POST /v1/tokens/path", async (t) => {
await t.test("returns the M1 not-implemented stub response", async () => {
await t.test("mints a relay_pa token pair from a workspace token", async () => {
const { app, authHeaders } = await createHarness({
authClaims: {
scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*"],
scopes: [
"relayauth:api-key:manage:*",
"relayauth:token:create:*",
"relayfile:fs:read:*",
"relayfile:fs:write:*",
],
},
});
const workspaceToken = await issueWorkspaceToken(app, authHeaders, {
scopes: ["relayauth:token:create:*", "relayfile:fs:read:*", "relayfile:fs:write:*"],
});

const response = await requestRoute(app, "POST", "/v1/tokens/path", {
body: {
workspaceId: "ws_tokens_route",
agentName: "cloud-orchestrator",
paths: ["/linear/issues/**"],
ttlSeconds: 7200,
},
headers: {
Authorization: `Bearer ${workspaceToken.key}`,
},
});

const body = await assertJsonResponse<PathTokenPair>(response, 201);
assert.equal(body.agentId, "agent_cloud-orchestrator");
assert.equal(body.agentName, "cloud-orchestrator");
assert.equal(body.workspaceId, "ws_tokens_route");
assert.equal(body.tokenClass, "relay_pa");
assert.deepEqual(body.paths, ["/linear/issues/*"]);
assert.match(body.accessToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
assert.match(body.refreshToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);

const accessClaims = decodeJwtJsonSegment<RelayAuthTokenClaims>(body.accessToken, 1);
assert.equal(accessClaims.sub, "agent_cloud-orchestrator");
assert.equal(accessClaims.meta?.tokenClass, "path");
assert.equal(accessClaims.meta?.workspaceTokenId, workspaceToken.workspaceToken.id);
assert.equal(accessClaims.meta?.agentName, "cloud-orchestrator");
assert.deepEqual(JSON.parse(accessClaims.meta?.paths ?? "[]"), ["/linear/issues/*"]);
assert.deepEqual(accessClaims.scopes, [
"relayfile:fs:read:/linear/issues/*",
"relayfile:fs:write:/linear/issues/*",
]);
assert.deepEqual(accessClaims.aud, ["relayfile"]);
assert.ok(accessClaims.exp - accessClaims.iat <= 3600, "path access TTL should cap at 1h");

const refreshResponse = await requestRoute(app, "POST", "/v1/tokens/refresh", {
body: {
refreshToken: body.refreshToken,
},
});
const refreshed = await assertJsonResponse<TokenPair>(refreshResponse, 200);
assert.match(refreshed.accessToken, /^relay_pa_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
const refreshedClaims = decodeJwtJsonSegment<RelayAuthTokenClaims>(refreshed.accessToken, 1);
assert.equal(refreshedClaims.meta?.tokenClass, "path");
assert.deepEqual(refreshedClaims.scopes, accessClaims.scopes);
});

await t.test("rejects path scopes outside the workspace token grant", async () => {
const { app, authHeaders } = await createHarness({
authClaims: {
scopes: ["relayauth:api-key:manage:*", "relayauth:token:create:*", "relayfile:fs:read:*"],
},
});
const workspaceToken = await issueWorkspaceToken(app, authHeaders, {
scopes: ["relayauth:token:create:*"],
scopes: ["relayauth:token:create:*", "relayfile:fs:read:*"],
});

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

await assertJsonResponse<ErrorBody>(response, 501, (body) => {
assert.equal(body.error, "path_scoped_tokens_not_implemented");
assert.equal(body.code, "not_implemented");
await assertJsonResponse<ErrorBody>(response, 403, (body) => {
assert.equal(body.code, "insufficient_scope");
});
});
});
Expand Down
17 changes: 14 additions & 3 deletions packages/server/src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export const RELAY_AGENT_TOKEN_PREFIX = "relay_ag_";
export const RELAY_PATH_TOKEN_PREFIX = "relay_pa_";
const RELAY_TOKEN_PREFIXES = [RELAY_AGENT_TOKEN_PREFIX, RELAY_PATH_TOKEN_PREFIX] as const;
export type RelayTokenPrefix = typeof RELAY_TOKEN_PREFIXES[number];

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

export function wrapRelayToken(token: string, prefix: RelayTokenPrefix): string {
return `${prefix}${token}`;
}

export function unwrapRelayToken(token: string): string {
return token.startsWith(RELAY_AGENT_TOKEN_PREFIX)
? token.slice(RELAY_AGENT_TOKEN_PREFIX.length)
: token;
for (const prefix of RELAY_TOKEN_PREFIXES) {
if (token.startsWith(prefix)) {
return token.slice(prefix.length);
}
}

return token;
}

export function splitJwtSegments(token: string): [string, string, string] | null {
Expand Down
19 changes: 17 additions & 2 deletions packages/server/src/middleware/api-key-auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { MiddlewareHandler } from "hono";
import type { AppEnv } from "../env.js";
import { WORKSPACE_TOKEN_PREFIX } from "../lib/api-keys.js";
import { authenticateBearerOrApiKey } from "../lib/auth.js";

/**
Expand All @@ -18,13 +19,14 @@ import { authenticateBearerOrApiKey } from "../lib/auth.js";
*/
export function apiKeyAuth(): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const apiKey = c.req.header("x-api-key");
const authorization = c.req.header("authorization");
const apiKey = c.req.header("x-api-key") ?? extractBearerWorkspaceToken(authorization);
if (!apiKey) {
return next();
}

const auth = await authenticateBearerOrApiKey(
c.req.header("authorization"),
authorization,
apiKey,
c.env,
c.get("storage"),
Expand All @@ -41,3 +43,16 @@ export function apiKeyAuth(): MiddlewareHandler<AppEnv> {
await next();
};
}

function extractBearerWorkspaceToken(authorization: string | undefined): string | undefined {
if (!authorization) {
return undefined;
}

const [scheme, token] = authorization.split(/\s+/, 2);
if (scheme !== "Bearer" || !token || !token.startsWith(WORKSPACE_TOKEN_PREFIX)) {
return undefined;
}

return token;
}
Loading
Loading