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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ npm install @relayauth/sdk
### Verify a token

```ts
import { TokenVerifier } from "@relayauth/core";
import { TokenVerifier } from "@relayauth/sdk";

const verifier = new TokenVerifier({ signingKey: process.env.SIGNING_KEY! });
const verifier = new TokenVerifier({
jwksUrl: "https://relayauth.example.com/.well-known/jwks.json",
issuer: "https://relayauth.dev",
});
const claims = await verifier.verify(token);
// claims.scopes → ["relayfile:fs:read:/github/*", "relayfile:fs:write:/github/*/reviews/*"]
```
Expand All @@ -55,7 +58,7 @@ checker.check("relayfile:fs:write:/slack/channels/general/messages/reply.json");
### Generate a dev token

```bash
SIGNING_KEY=my-secret \
RELAYAUTH_SIGNING_KEY_PEM="$(cat private.pem)" \
RELAYAUTH_SUB=review-agent \
RELAYAUTH_SCOPES_JSON='["relayfile:fs:read:/github/*", "relayfile:fs:write:/github/*/reviews/*"]' \
./scripts/generate-dev-token.sh
Expand All @@ -65,7 +68,9 @@ RELAYAUTH_SCOPES_JSON='["relayfile:fs:read:/github/*", "relayfile:fs:write:/gith

```bash
npm install
SIGNING_KEY=my-secret npm run start
RELAYAUTH_SIGNING_KEY_PEM="$(cat private.pem)" \
RELAYAUTH_SIGNING_KEY_PEM_PUBLIC="$(cat public.pem)" \
npm run start
```

## Scope Format
Expand Down
6 changes: 4 additions & 2 deletions packages/server/src/__tests__/audit-logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { createSqliteStorage } from "../storage/sqlite.js";
import {
createTestRequest,
generateTestToken,
TEST_RS256_PRIVATE_KEY_PEM,
TEST_RS256_PUBLIC_KEY_PEM,
} from "./test-helpers.js";

type ExtendedAuditAction =
Expand Down Expand Up @@ -51,9 +53,9 @@ function normalizeSql(query: string): string {

function createBindings(overrides: Partial<AppEnv["Bindings"]> = {}): AppEnv["Bindings"] {
return {
SIGNING_KEY: "dev-secret",
SIGNING_KEY_ID: "dev-key",
INTERNAL_SECRET: "internal-test-secret",
RELAYAUTH_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM,
RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: TEST_RS256_PUBLIC_KEY_PEM,
...overrides,
};
}
Expand Down
27 changes: 19 additions & 8 deletions packages/server/src/__tests__/auth-bearer-or-apikey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import assert from "node:assert/strict";
import crypto from "node:crypto";
import test from "node:test";
import type { RelayAuthTokenClaims } from "@relayauth/types";
import { generateTestToken } from "./test-helpers.js";
import type { AppEnv } from "../env.js";
import {
generateTestToken,
TEST_RS256_PRIVATE_KEY_PEM,
TEST_RS256_PUBLIC_KEY_PEM,
} from "./test-helpers.js";

type StoredApiKey = {
id: string;
Expand Down Expand Up @@ -33,10 +38,16 @@ type AuthenticateSuccess = {
type AuthenticateBearerOrApiKey = (
authorization: string | undefined,
apiKey: string | undefined,
signingKey: string,
env: AppEnv["Bindings"],
apiKeys: ApiKeyStorageLike,
) => Promise<AuthenticateSuccess | AuthenticateFailure>;

const TEST_BINDINGS: AppEnv["Bindings"] = {
INTERNAL_SECRET: "internal-test-secret",
RELAYAUTH_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM,
RELAYAUTH_SIGNING_KEY_PEM_PUBLIC: TEST_RS256_PUBLIC_KEY_PEM,
};

function hashApiKey(apiKey: string): string {
return crypto.createHash("sha256").update(apiKey).digest("hex");
}
Expand Down Expand Up @@ -110,7 +121,7 @@ test("changing one byte of the plaintext API key fails verification via SHA-256
const auth = await authenticateBearerOrApiKey(
undefined,
mutatedLastChar,
"dev-secret",
TEST_BINDINGS,
storage,
);

Expand All @@ -136,7 +147,7 @@ test("authenticateBearerOrApiKey accepts a valid bearer JWT and returns its clai
const auth = await authenticateBearerOrApiKey(
authorization,
undefined,
"dev-secret",
TEST_BINDINGS,
storage,
);

Expand All @@ -159,7 +170,7 @@ test("authenticateBearerOrApiKey accepts a valid x-api-key and returns synthesiz
const auth = await authenticateBearerOrApiKey(
undefined,
plaintext,
"dev-secret",
TEST_BINDINGS,
storage,
);

Expand All @@ -178,7 +189,7 @@ test("authenticateBearerOrApiKey returns 401 when neither bearer nor x-api-key i
const auth = await authenticateBearerOrApiKey(
undefined,
undefined,
"dev-secret",
TEST_BINDINGS,
storage,
);

Expand Down Expand Up @@ -206,7 +217,7 @@ test("when both are present and conflict, a valid bearer wins and API-key lookup
const auth = await authenticateBearerOrApiKey(
authorization,
"rak_test_auth_plaintext_fixture",
"dev-secret",
TEST_BINDINGS,
storage,
);

Expand All @@ -227,7 +238,7 @@ test("authenticateBearerOrApiKey updates last_used_at when an API key authentica
const auth = await authenticateBearerOrApiKey(
undefined,
"rak_test_auth_plaintext_fixture",
"dev-secret",
TEST_BINDINGS,
storage,
);

Expand Down
19 changes: 2 additions & 17 deletions packages/server/src/__tests__/create-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createTestApp,
createTestRequest,
generateTestIdentity,
generateTestToken,
seedOrgBudget,
seedStoredIdentity,
} from "./test-helpers.js";
Expand All @@ -25,22 +26,6 @@ type CreatedIdentity = AgentIdentity & {
budget?: IdentityBudget;
};

function base64UrlEncode(value: string | Buffer): string {
return Buffer.from(value).toString("base64url");
}

function signHs256(payload: Record<string, unknown>, secret: string): string {
const header = { alg: "HS256", typ: "JWT" };
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const unsigned = `${encodedHeader}.${encodedPayload}`;
const signature = crypto
.createHmac("sha256", secret)
.update(unsigned)
.digest("base64url");
return `${unsigned}.${signature}`;
}

function createAuthToken(overrides: Partial<RelayAuthTokenClaims> = {}): string {
const now = Math.floor(Date.now() / 1000);
const sponsorId = overrides.sponsorId ?? "user_sponsor_1";
Expand All @@ -66,7 +51,7 @@ function createAuthToken(overrides: Partial<RelayAuthTokenClaims> = {}): string
budget: overrides.budget,
};

return signHs256(payload as Record<string, unknown>, "dev-secret");
return generateTestToken(payload);
}


Expand Down
20 changes: 14 additions & 6 deletions packages/server/src/__tests__/dev-environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,16 @@ test("seed data script creates valid test identities", async () => {

test("dev token generator produces valid JWT structure", async () => {
await access(paths.tokenScript);
const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();

const token = execFileSync("bash", [paths.tokenScript], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
RELAYAUTH_SIGNING_KEY_PEM: privateKeyPem,
},
}).trim();

const parts = token.split(".");
Expand All @@ -92,14 +98,16 @@ test("dev token generator produces valid JWT structure", async () => {
const header = decodeJwtPart(encodedHeader);
const payload = decodeJwtPart(encodedPayload);

assert.deepEqual(header, { alg: "HS256", typ: "JWT", kid: "dev-key" });
assert.deepEqual(header, { alg: "RS256", typ: "JWT" });
assert.match(signature, /^[A-Za-z0-9_-]+$/, "JWT signature should be base64url encoded");

const expectedSignature = crypto
.createHmac("sha256", "dev-secret")
.update(`${encodedHeader}.${encodedPayload}`)
.digest("base64url");
assert.equal(signature, expectedSignature, "JWT should be signed with the dev secret");
const validSignature = crypto.verify(
"RSA-SHA256",
Buffer.from(`${encodedHeader}.${encodedPayload}`),
publicKey,
Buffer.from(signature, "base64url"),
);
assert.equal(validSignature, true, "JWT should be signed with the configured RSA private key");

assert.equal(payload.sub, "agent_dev_admin");
assert.equal(payload.org, "org_dev");
Expand Down
24 changes: 20 additions & 4 deletions packages/server/src/__tests__/discovery-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ test("POST /v1/discovery/bridge fetches an external agent card and returns Agent
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input.toString() : input.toString();
if (url.startsWith("data:")) {
return originalFetch(input);
}
assert.equal(url, "https://agent.example.com/.well-known/agent-card.json");

return new Response(
Expand Down Expand Up @@ -105,6 +108,10 @@ test("POST /v1/discovery/bridge blocks redirects to private hosts before followi
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls += 1;
const url = input instanceof URL ? input.toString() : input.toString();
if (url.startsWith("data:")) {
calls -= 1;
return originalFetch(input, init);
}

if (calls === 1) {
assert.equal(url, "https://agent.example.com/.well-known/agent-card.json");
Expand Down Expand Up @@ -138,11 +145,16 @@ test("POST /v1/discovery/bridge blocks redirects to private hosts before followi

test("POST /v1/discovery/bridge returns 422 for invalid agent cards", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ url: "https://agent.example.com/rpc" }), {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input.toString() : input.toString();
if (url.startsWith("data:")) {
return originalFetch(input);
}
return new Response(JSON.stringify({ url: "https://agent.example.com/rpc" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as typeof fetch;
});
}) as typeof fetch;

try {
const app = createTestApp();
Expand All @@ -161,7 +173,11 @@ test("POST /v1/discovery/bridge returns 422 for invalid agent cards", async () =

test("POST /v1/discovery/bridge returns 502 when the upstream agent card cannot be reached", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input.toString() : input.toString();
if (url.startsWith("data:")) {
return originalFetch(input);
}
throw new Error("connect ECONNREFUSED");
}) as typeof fetch;

Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/__tests__/e2e/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ test("Audit & Observability E2E", async (t) => {
const originalFetch = globalThis.fetch;

globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : input instanceof URL ? input.toString() : String(input);
if (url.startsWith("data:")) {
return originalFetch(input, init);
}
const request = input instanceof Request ? input : new Request(String(input), init);
const body = await request.text();
requests.push({ request, body });
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/__tests__/e2e/rbac.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ function createScopeIssuanceApp(storage: AuthStorage): Hono<AppEnv> {
});

app.post("/subagents", async (c) => {
const auth = await authenticate(c.req.header("Authorization"), c.env.SIGNING_KEY);
const auth = await authenticate(c.req.header("Authorization"), c.env);
if (!auth.ok) {
return c.json({ error: auth.error, code: "invalid_authorization" }, auth.status);
}
Expand Down
21 changes: 8 additions & 13 deletions packages/server/src/__tests__/e2e/sdk-verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,6 @@ const WORKSPACE_ID = "ws_sdk_verification_e2e";
const ADMIN_SCOPE = "relayauth:admin:*";
const READ_SCOPE = "relayauth:identity:read:*";
const DEFAULT_AUDIENCE = ["relayauth-sdk", "relay-api"];
const SIGNING_KEY = "dev-secret";

test("SDK & Verification E2E", async (t) => {
const harness = await createSdkVerificationHarness();
t.after(async () => {
Expand Down Expand Up @@ -329,17 +327,14 @@ async function createSdkVerificationHarness() {
const baseUrl = `http://relayauth-sdk-verification.${randomUUID()}.local`;
const jwksUrl = `${baseUrl}/.well-known/jwks.json`;
const revocationUrl = `${baseUrl}/v1/tokens/revocation`;
const adminToken = generateTestToken(
{
sub: "agent_sdk_admin",
org: ORG_ID,
wks: WORKSPACE_ID,
scopes: ["relayauth:*:*:*"],
sponsorId: "user_sdk_admin",
sponsorChain: ["user_sdk_admin", "agent_sdk_admin"],
},
SIGNING_KEY,
);
const adminToken = generateTestToken({
sub: "agent_sdk_admin",
org: ORG_ID,
wks: WORKSPACE_ID,
scopes: ["relayauth:*:*:*"],
sponsorId: "user_sdk_admin",
sponsorChain: ["user_sdk_admin", "agent_sdk_admin"],
});
const fetchHarness = createFetchDispatchHarness(baseUrl, dispatch);

async function dispatch(request: Request): Promise<Response> {
Expand Down
Loading
Loading