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
4 changes: 2 additions & 2 deletions packages/sdk/src/__tests__/provisioner-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';

import { ensureRelayfileMount } from '../provisioner/mount.js';
import { provisionWorkflowAgents } from '../provisioner/index.js';
import { createLocalJwksKeyPair, provisionWorkflowAgents } from '../provisioner/index.js';

const tempDirs: string[] = [];

Expand Down Expand Up @@ -103,7 +103,7 @@ describe('provisionWorkflowAgents mount integration', () => {
await writeFile(path.join(projectDir, 'src', 'index.ts'), 'export const value = 1;\n');

const result = await provisionWorkflowAgents({
secret: 'test-secret',
tokenSigningKey: createLocalJwksKeyPair(),
workspace: 'rw_workspace',
projectDir,
relayfileBaseUrl: 'http://127.0.0.1:8080',
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/provisioner/__tests__/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';

import { provisionWorkflowAgents } from '../index.js';
import { createLocalJwksKeyPair, provisionWorkflowAgents } from '../index.js';

async function createWorkspace(): Promise<{ dir: string; cleanup: () => Promise<void> }> {
const dir = await mkdtemp(path.join(tmpdir(), 'relay-provisioner-audit-'));
Expand All @@ -22,7 +22,7 @@ test('provisionWorkflowAgents writes a permission audit without token values', a

try {
const result = await provisionWorkflowAgents({
secret: 'test-secret',
tokenSigningKey: createLocalJwksKeyPair(),
workspace: 'audit-workspace',
projectDir: workspace.dir,
relayfileBaseUrl: 'http://127.0.0.1:8080',
Expand Down
48 changes: 31 additions & 17 deletions packages/sdk/src/provisioner/__tests__/token-factory.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { createPublicKey, createVerify } from 'node:crypto';
import test from 'node:test';

import { createLocalJwksKeyPair } from '../local-jwks.js';
import {
DEFAULT_ADMIN_AGENT_NAME,
DEFAULT_ADMIN_SCOPES,
Expand All @@ -14,6 +15,7 @@ import {
interface JwtHeader {
alg: string;
typ: string;
kid: string;
}

function decodeJwtPart<T>(value: string): T {
Expand All @@ -33,9 +35,15 @@ function decodeJwt(token: string): { header: JwtHeader; payload: TokenClaims; si
};
}

function testSigningKey() {
const { privateKey, kid } = createLocalJwksKeyPair();
return { privateKey, kid };
}

test('mintAgentToken returns a valid JWT', () => {
const signingKey = testSigningKey();
const token = mintAgentToken({
secret: 'test-secret',
...signingKey,
agentName: 'worker',
workspace: 'workspace-123',
scopes: ['relayfile:fs:read:/src/index.ts'],
Expand All @@ -46,14 +54,15 @@ test('mintAgentToken returns a valid JWT', () => {

assert.equal(parts.length, 3);
assert.ok(parts.every((part) => /^[A-Za-z0-9_-]+$/u.test(part)));
assert.deepEqual(decoded.header, { alg: 'HS256', typ: 'JWT' });
assert.deepEqual(decoded.header, { alg: 'RS256', typ: 'JWT', kid: signingKey.kid });
assert.equal(decoded.payload.sub, 'agent_worker');
});

test('mintAgentToken payload contains agent_name, workspace, and scopes', () => {
const signingKey = testSigningKey();
const scopes = ['relayfile:fs:read:/src/index.ts', 'relayfile:fs:write:/src/index.ts'];
const token = mintAgentToken({
secret: 'test-secret',
...signingKey,
agentName: 'compiler',
workspace: 'workspace-abc',
scopes,
Expand All @@ -69,7 +78,7 @@ test('mintAgentToken payload contains agent_name, workspace, and scopes', () =>

test('mintAgentToken defaults expiry to 2 hours', () => {
const token = mintAgentToken({
secret: 'test-secret',
...testSigningKey(),
agentName: 'worker',
workspace: 'workspace-123',
scopes: [],
Expand All @@ -83,7 +92,7 @@ test('mintAgentToken defaults expiry to 2 hours', () => {

test('mintAgentToken applies a custom TTL', () => {
const token = mintAgentToken({
secret: 'test-secret',
...testSigningKey(),
agentName: 'worker',
workspace: 'workspace-123',
scopes: [],
Expand All @@ -96,7 +105,8 @@ test('mintAgentToken applies a custom TTL', () => {
});

test('WorkflowTokenFactory mintAdmin uses the default admin identity and scopes', () => {
const factory = new WorkflowTokenFactory('test-secret', 'workspace-admin');
const signingKey = testSigningKey();
const factory = new WorkflowTokenFactory(signingKey.privateKey, signingKey.kid, 'workspace-admin');
const token = factory.mintAdmin();
const { payload } = decodeJwt(token);

Expand All @@ -106,14 +116,16 @@ test('WorkflowTokenFactory mintAdmin uses the default admin identity and scopes'
});

test('WorkflowTokenFactory getToken returns the token minted for an agent', () => {
const factory = new WorkflowTokenFactory('test-secret', 'workspace-123');
const signingKey = testSigningKey();
const factory = new WorkflowTokenFactory(signingKey.privateKey, signingKey.kid, 'workspace-123');
const token = factory.mintForAgent('builder', ['relayfile:fs:read:/src/index.ts']);

assert.equal(factory.getToken('builder'), token);
});

test('WorkflowTokenFactory uses its configured TTL when minting agent tokens', () => {
const factory = new WorkflowTokenFactory('test-secret', 'workspace-123', 45);
const signingKey = testSigningKey();
const factory = new WorkflowTokenFactory(signingKey.privateKey, signingKey.kid, 'workspace-123', 45);
const token = factory.mintForAgent('builder', []);
const { payload } = decodeJwt(token);

Expand All @@ -123,15 +135,15 @@ test('WorkflowTokenFactory uses its configured TTL when minting agent tokens', (
test('mintAgentToken generates a unique JTI per token', () => {
const first = decodeJwt(
mintAgentToken({
secret: 'test-secret',
...testSigningKey(),
agentName: 'worker',
workspace: 'workspace-123',
scopes: [],
})
).payload;
const second = decodeJwt(
mintAgentToken({
secret: 'test-secret',
...testSigningKey(),
agentName: 'worker',
workspace: 'workspace-123',
scopes: [],
Expand All @@ -145,7 +157,7 @@ test('mintAgentToken generates a unique JTI per token', () => {

test('mintAgentToken includes the expected audience claims', () => {
const token = mintAgentToken({
secret: 'test-secret',
...testSigningKey(),
agentName: 'worker',
workspace: 'workspace-123',
scopes: [],
Expand All @@ -156,17 +168,19 @@ test('mintAgentToken includes the expected audience claims', () => {
assert.deepEqual(payload.aud, ['relayauth', 'relayfile']);
});

test('mintAgentToken signs tokens with HMAC-SHA256', () => {
const secret = 'test-secret';
test('mintAgentToken signs tokens with RS256', () => {
const signingKey = testSigningKey();
const token = mintAgentToken({
secret,
...signingKey,
agentName: 'worker',
workspace: 'workspace-123',
scopes: ['relayfile:fs:read:/src/index.ts'],
});

const [header, payload, signature] = token.split('.');
const expectedSignature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
const verifier = createVerify('RSA-SHA256');
verifier.update(`${header}.${payload}`);
verifier.end();

assert.equal(signature, expectedSignature);
assert.equal(verifier.verify(createPublicKey(signingKey.privateKey), signature, 'base64url'), true);
});
12 changes: 9 additions & 3 deletions packages/sdk/src/provisioner/__tests__/token.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { createLocalJwksKeyPair } from '../local-jwks.js';
import { DEFAULT_WORKFLOW_TOKEN_TTL_SECONDS, mintAgentToken, type TokenClaims } from '../token.js';

function decodeJwtPayload(token: string): TokenClaims {
const [, payload] = token.split('.');
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as TokenClaims;
}

function testSigningKey() {
const { privateKey, kid } = createLocalJwksKeyPair();
return { privateKey, kid };
}

test('mintAgentToken returns a valid JWT', () => {
const token = mintAgentToken({
secret: 'test-secret',
...testSigningKey(),
agentName: 'worker',
workspace: 'workspace-123',
scopes: ['relayfile:fs:read:/src/index.ts'],
Expand All @@ -24,7 +30,7 @@ test('mintAgentToken returns a valid JWT', () => {
test('mintAgentToken payload contains agent_name, workspace, and scopes', () => {
const scopes = ['relayfile:fs:read:/src/index.ts', 'relayfile:fs:write:/src/index.ts'];
const token = mintAgentToken({
secret: 'test-secret',
...testSigningKey(),
agentName: 'compiler',
workspace: 'workspace-abc',
scopes,
Expand All @@ -40,7 +46,7 @@ test('mintAgentToken payload contains agent_name, workspace, and scopes', () =>

test('mintAgentToken defaults expiry to 2 hours', () => {
const token = mintAgentToken({
secret: 'test-secret',
...testSigningKey(),
agentName: 'worker',
workspace: 'workspace-123',
scopes: [],
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/provisioner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
} from './types.js';

export * from './compiler.js';
export * from './local-jwks.js';
export * from './mount.js';
export * from './seeder.js';
export * from './token.js';
Expand Down Expand Up @@ -147,7 +148,8 @@ export async function provisionWorkflowAgents(config: WorkflowProvisionConfig):
permissions: agent.permissions,
});
const token = mintAgentToken({
secret: config.secret,
privateKey: config.tokenSigningKey.privateKey,
kid: config.tokenSigningKey.kid,
agentName: agent.name,
workspace: config.workspace,
scopes: compiled.scopes,
Expand All @@ -174,7 +176,8 @@ export async function provisionWorkflowAgents(config: WorkflowProvisionConfig):

const adminScopes = [...(config.adminScopes ?? DEFAULT_ADMIN_SCOPES)];
const adminToken = mintAgentToken({
secret: config.secret,
privateKey: config.tokenSigningKey.privateKey,
kid: config.tokenSigningKey.kid,
agentName: DEFAULT_ADMIN_AGENT_NAME,
workspace: config.workspace,
scopes: adminScopes,
Expand Down
104 changes: 104 additions & 0 deletions packages/sdk/src/provisioner/local-jwks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
createHash,
createPrivateKey,
generateKeyPairSync,
type KeyObject,
} from 'node:crypto';
import { createServer as createHttpServer, type Server } from 'node:http';

export const RELAYAUTH_JWKS_URL_ENV = 'RELAYAUTH_JWKS_URL';
export const RELAYAUTH_JWT_PRIVATE_KEY_PEM_ENV = 'RELAYAUTH_JWT_PRIVATE_KEY_PEM';
export const RELAYAUTH_JWT_KID_ENV = 'RELAYAUTH_JWT_KID';

export interface RsaPublicJwk {
kty: string;
n: string;
e: string;
}

export interface LocalJwksSigningKey {
privateKey: KeyObject;
kid: string;
}

export interface LocalJwksKeyPair extends LocalJwksSigningKey {
publicJwk: RsaPublicJwk;
}

export interface LocalJwks extends LocalJwksKeyPair {
jwksUrl: string;
shutdown: () => Promise<void>;
}

export function createLocalJwksKeyPair(): LocalJwksKeyPair {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicJwk = publicKey.export({ format: 'jwk' }) as RsaPublicJwk;
const kid = createHash('sha256')
.update(JSON.stringify({ e: publicJwk.e, kty: 'RSA', n: publicJwk.n }))
.digest('base64url');

return { privateKey, publicJwk, kid };
}

export function exportPrivateKeyPem(privateKey: KeyObject): string {
return privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
}

export function importPrivateKeyPem(privateKeyPem: string): KeyObject {
return createPrivateKey(privateKeyPem);
}

export async function createLocalJwks(): Promise<LocalJwks> {
const keyPair = createLocalJwksKeyPair();
const jwk = {
...keyPair.publicJwk,
kty: 'RSA',
alg: 'RS256',
use: 'sig',
kid: keyPair.kid,
};
const server = createHttpServer((_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ keys: [jwk] }));
});

await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve();
});
});

const address = server.address();
if (!address || typeof address === 'string') {
await closeServer(server);
throw new Error('local JWKS server did not bind to a TCP port');
}

server.unref();
let closed = false;
return {
...keyPair,
jwksUrl: `http://127.0.0.1:${address.port}/.well-known/jwks.json`,
shutdown: async () => {
if (closed) {
return;
}
closed = true;
await closeServer(server);
},
};
}

function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
Loading
Loading