feat(agiliton-account): Phase 1 service scaffold
TypeScript + Fastify service implementing:
- Google + Microsoft SSO (POST /v1/auth/sso/{google,microsoft})
- JWT issuance + LiteLLM virtual key provisioning on first login
- AES-256-GCM encrypted virtual key storage in Postgres
- Conversation CRUD (GET/POST/DELETE /v1/conversations, /messages)
- GDPR export + soft-delete (/v1/me/export, /v1/me/delete)
- WebSocket MCP bridge (/v1/mcp-bridge) with JWT auth
- MCP demux endpoint (/mcp/demux/:customer_id/mcp) for LiteLLM tool routing
- DB migration script creating sb_customers, sb_conversations, sb_messages
- 9 unit tests (bridge + crypto), all passing
- Dockerfile + docker-compose targeting port 4100
CF-3032
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
src/__tests__/bridge.test.ts
Normal file
117
src/__tests__/bridge.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock config before importing bridge
|
||||
vi.mock("../config.js", () => ({
|
||||
config: {
|
||||
mcpBridgeTimeoutMs: 1000,
|
||||
jwtSecret: "test-secret",
|
||||
encryptionKey: "0".repeat(64),
|
||||
litellmUrl: "http://localhost:4000",
|
||||
litellmMasterKey: "test-key",
|
||||
mcpBridgeSecret: "bridge-secret",
|
||||
defaultBudgetUsd: 30,
|
||||
defaultBudgetDuration: "30d",
|
||||
defaultRpmLimit: 30,
|
||||
defaultModels: ["claude-sonnet-4-6"],
|
||||
googleClientId: "test",
|
||||
googleClientSecret: "test",
|
||||
msClientId: "",
|
||||
msTenantId: "common",
|
||||
dbUrl: "postgres://localhost/test",
|
||||
redisUrl: "redis://localhost:6379",
|
||||
port: 4100,
|
||||
host: "0.0.0.0",
|
||||
jwtExpiresIn: "7d",
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
registerCustomerWs,
|
||||
forwardMcpCall,
|
||||
isCustomerOnline,
|
||||
McpBridgeError,
|
||||
} from "../mcp/bridge.js";
|
||||
|
||||
function makeMockWs() {
|
||||
const listeners: Record<string, Array<(data: unknown) => void>> = {};
|
||||
return {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
on: (event: string, cb: (data: unknown) => void) => {
|
||||
listeners[event] = listeners[event] ?? [];
|
||||
listeners[event].push(cb);
|
||||
},
|
||||
emit: (event: string, data: unknown) => {
|
||||
listeners[event]?.forEach((cb) => cb(data));
|
||||
},
|
||||
_listeners: listeners,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MCP bridge", () => {
|
||||
beforeEach(() => {
|
||||
// Clear sessions between tests by re-importing is complex; use unique IDs
|
||||
});
|
||||
|
||||
it("registers a customer WS and marks them online", () => {
|
||||
const ws = makeMockWs();
|
||||
registerCustomerWs("cust-1", ws as never);
|
||||
expect(isCustomerOnline("cust-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards a call and resolves on WS response", async () => {
|
||||
const ws = makeMockWs();
|
||||
registerCustomerWs("cust-2", ws as never);
|
||||
|
||||
const callPromise = forwardMcpCall(
|
||||
"cust-2",
|
||||
{ method: "tools/call", params: { name: "screenshot" }, id: "req-1" },
|
||||
5000
|
||||
);
|
||||
|
||||
// Simulate extension responding
|
||||
ws.emit("message", Buffer.from(JSON.stringify({ id: "req-1", result: { image: "base64data" } })));
|
||||
|
||||
const result = await callPromise;
|
||||
expect(result).toEqual({ image: "base64data" });
|
||||
});
|
||||
|
||||
it("rejects with McpBridgeError when customer is offline", async () => {
|
||||
await expect(
|
||||
forwardMcpCall("offline-cust", { method: "tools/call", id: "req-2" }, 100)
|
||||
).rejects.toThrow(McpBridgeError);
|
||||
});
|
||||
|
||||
it("rejects with McpBridgeError on timeout", async () => {
|
||||
const ws = makeMockWs();
|
||||
registerCustomerWs("cust-3", ws as never);
|
||||
|
||||
await expect(
|
||||
forwardMcpCall(
|
||||
"cust-3",
|
||||
{ method: "tools/call", id: "req-3" },
|
||||
50 // 50ms timeout
|
||||
)
|
||||
).rejects.toThrow("timeout");
|
||||
});
|
||||
|
||||
it("marks customer offline after WS close", () => {
|
||||
const ws = makeMockWs();
|
||||
registerCustomerWs("cust-4", ws as never);
|
||||
expect(isCustomerOnline("cust-4")).toBe(true);
|
||||
|
||||
ws.emit("close", undefined);
|
||||
expect(isCustomerOnline("cust-4")).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces stale WS on second registration", () => {
|
||||
const ws1 = makeMockWs();
|
||||
const ws2 = makeMockWs();
|
||||
|
||||
registerCustomerWs("cust-5", ws1 as never);
|
||||
registerCustomerWs("cust-5", ws2 as never);
|
||||
|
||||
expect(ws1.close).toHaveBeenCalled();
|
||||
expect(isCustomerOnline("cust-5")).toBe(true);
|
||||
});
|
||||
});
|
||||
30
src/__tests__/crypto.test.ts
Normal file
30
src/__tests__/crypto.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("../config.js", () => ({
|
||||
config: {
|
||||
encryptionKey: "a".repeat(64), // 32-byte hex key
|
||||
},
|
||||
}));
|
||||
|
||||
import { encrypt, decrypt } from "../utils/crypto.js";
|
||||
|
||||
describe("AES-256-GCM encrypt/decrypt", () => {
|
||||
it("round-trips a plaintext string", () => {
|
||||
const original = "sk-litellm-abc123supersecret";
|
||||
const buf = encrypt(original);
|
||||
expect(buf.length).toBeGreaterThan(28);
|
||||
expect(decrypt(buf)).toBe(original);
|
||||
});
|
||||
|
||||
it("produces different ciphertexts for the same input (random IV)", () => {
|
||||
const key = "same-key-test";
|
||||
const buf1 = encrypt(key);
|
||||
const buf2 = encrypt(key);
|
||||
expect(buf1.toString("hex")).not.toBe(buf2.toString("hex"));
|
||||
});
|
||||
|
||||
it("round-trips a unicode string", () => {
|
||||
const original = "sk-🔑-unicode-test-αβγ";
|
||||
expect(decrypt(encrypt(original))).toBe(original);
|
||||
});
|
||||
});
|
||||
157
src/auth/customers.ts
Normal file
157
src/auth/customers.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { query, queryOne } from "../db/pool.js";
|
||||
import { encrypt, decrypt } from "../utils/crypto.js";
|
||||
import { provisionLiteLLMKey } from "./litellm.js";
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
sso_provider: string;
|
||||
sso_sub: string;
|
||||
tier: string;
|
||||
system_prompt: string | null;
|
||||
preferred_model: string | null;
|
||||
litellm_key_alias: string;
|
||||
litellm_key_encrypted: Buffer;
|
||||
created_at: Date;
|
||||
last_login_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
}
|
||||
|
||||
export interface CustomerPublic {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
tier: string;
|
||||
system_prompt: string | null;
|
||||
preferred_model: string | null;
|
||||
}
|
||||
|
||||
export function toPublic(c: Customer): CustomerPublic {
|
||||
return {
|
||||
id: c.id,
|
||||
email: c.email,
|
||||
name: c.name,
|
||||
tier: c.tier,
|
||||
system_prompt: c.system_prompt,
|
||||
preferred_model: c.preferred_model,
|
||||
};
|
||||
}
|
||||
|
||||
export function getVirtualKey(c: Customer): string {
|
||||
return decrypt(c.litellm_key_encrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a customer from SSO data. Provisions a LiteLLM key on first login.
|
||||
* Returns the customer row and the plaintext virtual key.
|
||||
*/
|
||||
export async function upsertCustomer(params: {
|
||||
sso_provider: string;
|
||||
sso_sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}): Promise<{ customer: Customer; virtualKey: string; isNew: boolean }> {
|
||||
// Check for existing customer (not deleted)
|
||||
const existing = await queryOne<Customer>(
|
||||
`SELECT * FROM sb_customers
|
||||
WHERE sso_provider = $1 AND sso_sub = $2 AND deleted_at IS NULL`,
|
||||
[params.sso_provider, params.sso_sub]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Update last_login_at and name
|
||||
await query(
|
||||
`UPDATE sb_customers SET last_login_at = NOW(), name = $1 WHERE id = $2`,
|
||||
[params.name, existing.id]
|
||||
);
|
||||
existing.last_login_at = new Date();
|
||||
existing.name = params.name;
|
||||
return { customer: existing, virtualKey: getVirtualKey(existing), isNew: false };
|
||||
}
|
||||
|
||||
// First login — provision LiteLLM key, then insert
|
||||
// We need the customer ID before the key alias, so generate a UUID first
|
||||
const { v4: uuidv4 } = await import("uuid");
|
||||
const customerId = uuidv4();
|
||||
const virtualKey = await provisionLiteLLMKey(customerId, params.email);
|
||||
const keyAlias = `sb-${customerId}`;
|
||||
const encryptedKey = encrypt(virtualKey);
|
||||
|
||||
const [customer] = await query<Customer>(
|
||||
`INSERT INTO sb_customers
|
||||
(id, email, name, sso_provider, sso_sub, litellm_key_alias, litellm_key_encrypted, last_login_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
RETURNING *`,
|
||||
[customerId, params.email, params.name, params.sso_provider, params.sso_sub, keyAlias, encryptedKey]
|
||||
);
|
||||
|
||||
return { customer, virtualKey, isNew: true };
|
||||
}
|
||||
|
||||
export async function getCustomerById(id: string): Promise<Customer | null> {
|
||||
return queryOne<Customer>(
|
||||
`SELECT * FROM sb_customers WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateCustomerConfig(
|
||||
id: string,
|
||||
patch: { system_prompt?: string | null; preferred_model?: string | null }
|
||||
): Promise<void> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
|
||||
if ("system_prompt" in patch) {
|
||||
fields.push(`system_prompt = $${i++}`);
|
||||
values.push(patch.system_prompt);
|
||||
}
|
||||
if ("preferred_model" in patch) {
|
||||
fields.push(`preferred_model = $${i++}`);
|
||||
values.push(patch.preferred_model);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
values.push(id);
|
||||
|
||||
await query(
|
||||
`UPDATE sb_customers SET ${fields.join(", ")} WHERE id = $${i}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
export async function softDeleteCustomer(id: string): Promise<void> {
|
||||
await query(
|
||||
`UPDATE sb_customers SET deleted_at = NOW() WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportCustomerData(id: string): Promise<object> {
|
||||
const customer = await getCustomerById(id);
|
||||
if (!customer) return {};
|
||||
|
||||
const conversations = await query(
|
||||
`SELECT id, title, created_at, updated_at FROM sb_conversations
|
||||
WHERE customer_id = $1 ORDER BY updated_at DESC`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const messages = await query(
|
||||
`SELECT m.id, m.conversation_id, m.role, m.content, m.created_at
|
||||
FROM sb_messages m
|
||||
JOIN sb_conversations c ON c.id = m.conversation_id
|
||||
WHERE c.customer_id = $1
|
||||
ORDER BY m.created_at`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
customer: toPublic(customer),
|
||||
conversations,
|
||||
messages,
|
||||
exported_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
40
src/auth/google.ts
Normal file
40
src/auth/google.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const client = new OAuth2Client(config.googleClientId, config.googleClientSecret);
|
||||
|
||||
export interface GoogleProfile {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorization code from chrome.identity.launchWebAuthFlow for
|
||||
* a Google ID token, then verify and return the user profile.
|
||||
*
|
||||
* redirectUri must match what was registered in the Google Cloud Console AND
|
||||
* what chrome.identity uses (typically https://<ext-id>.chromiumapp.org/).
|
||||
*/
|
||||
export async function exchangeGoogleCode(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<GoogleProfile> {
|
||||
const { tokens } = await client.getToken({ code, redirect_uri: redirectUri });
|
||||
if (!tokens.id_token) throw new Error("No id_token in Google response");
|
||||
|
||||
const ticket = await client.verifyIdToken({
|
||||
idToken: tokens.id_token,
|
||||
audience: config.googleClientId,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload?.sub || !payload.email) {
|
||||
throw new Error("Invalid Google token payload");
|
||||
}
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name ?? payload.email,
|
||||
};
|
||||
}
|
||||
52
src/auth/litellm.ts
Normal file
52
src/auth/litellm.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { config } from "../config.js";
|
||||
|
||||
interface LiteLLMKeyResponse {
|
||||
key: string;
|
||||
key_alias: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a new virtual key for a customer. Called once at first login.
|
||||
*/
|
||||
export async function provisionLiteLLMKey(customerId: string, email: string): Promise<string> {
|
||||
const body = {
|
||||
key_alias: `sb-${customerId}`,
|
||||
models: config.defaultModels,
|
||||
mcp_servers: ["sitebridge"],
|
||||
max_budget: config.defaultBudgetUsd,
|
||||
budget_duration: config.defaultBudgetDuration,
|
||||
rpm_limit: config.defaultRpmLimit,
|
||||
metadata: { customer_id: customerId, email },
|
||||
};
|
||||
|
||||
const res = await fetch(`${config.litellmUrl}/key/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.litellmMasterKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`LiteLLM key/generate failed ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as LiteLLMKeyResponse;
|
||||
return data.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a virtual key's metadata via LiteLLM.
|
||||
*/
|
||||
export async function getLiteLLMKeyInfo(keyAlias: string): Promise<unknown> {
|
||||
const res = await fetch(
|
||||
`${config.litellmUrl}/key/info?key_alias=${encodeURIComponent(keyAlias)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${config.litellmMasterKey}` },
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`LiteLLM key/info failed ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
56
src/config.ts
Normal file
56
src/config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
function env(key: string, fallback?: string): string {
|
||||
const val = process.env[key] ?? fallback;
|
||||
if (val === undefined) throw new Error(`Missing required env var: ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
function envOptional(key: string, fallback = ""): string {
|
||||
return process.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: parseInt(envOptional("PORT", "4100")),
|
||||
host: envOptional("HOST", "0.0.0.0"),
|
||||
|
||||
// JWT
|
||||
jwtSecret: env("JWT_SECRET"),
|
||||
jwtExpiresIn: envOptional("JWT_EXPIRES_IN", "7d"),
|
||||
|
||||
// Database
|
||||
dbUrl: env("DATABASE_URL"),
|
||||
|
||||
// Redis
|
||||
redisUrl: envOptional("REDIS_URL", "redis://localhost:6379"),
|
||||
|
||||
// Encryption key for LiteLLM virtual keys stored in DB (32-byte hex)
|
||||
encryptionKey: env("ENCRYPTION_KEY"),
|
||||
|
||||
// LiteLLM
|
||||
litellmUrl: envOptional("LITELLM_URL", "http://litellm:4000"),
|
||||
litellmMasterKey: env("LITELLM_MASTER_KEY"),
|
||||
|
||||
// Shared secret used by LiteLLM when calling /mcp/demux/:id/mcp
|
||||
mcpBridgeSecret: env("MCP_BRIDGE_SECRET"),
|
||||
|
||||
// Google OAuth
|
||||
googleClientId: env("GOOGLE_CLIENT_ID"),
|
||||
googleClientSecret: env("GOOGLE_CLIENT_SECRET"),
|
||||
|
||||
// Microsoft OAuth
|
||||
msClientId: envOptional("MS_CLIENT_ID"),
|
||||
msTenantId: envOptional("MS_TENANT_ID", "common"),
|
||||
|
||||
// Default budget per customer (USD, 30 days)
|
||||
defaultBudgetUsd: parseFloat(envOptional("DEFAULT_BUDGET_USD", "30.0")),
|
||||
defaultBudgetDuration: envOptional("DEFAULT_BUDGET_DURATION", "30d"),
|
||||
defaultRpmLimit: parseInt(envOptional("DEFAULT_RPM_LIMIT", "30")),
|
||||
defaultModels: envOptional(
|
||||
"DEFAULT_MODELS",
|
||||
"claude-sonnet-4-6,claude-opus-4-6,grok-heavy"
|
||||
).split(","),
|
||||
|
||||
// WebSocket MCP bridge timeout (ms)
|
||||
mcpBridgeTimeoutMs: parseInt(envOptional("MCP_BRIDGE_TIMEOUT_MS", "15000")),
|
||||
} as const;
|
||||
109
src/db/conversations.ts
Normal file
109
src/db/conversations.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { query, queryOne } from "./pool.js";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
customer_id: string;
|
||||
title: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
response_id: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export async function listConversations(
|
||||
customerId: string,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<Conversation[]> {
|
||||
return query<Conversation>(
|
||||
`SELECT id, customer_id, title, created_at, updated_at
|
||||
FROM sb_conversations
|
||||
WHERE customer_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[customerId, limit, offset]
|
||||
);
|
||||
}
|
||||
|
||||
export async function createConversation(
|
||||
customerId: string,
|
||||
title?: string
|
||||
): Promise<Conversation> {
|
||||
const [row] = await query<Conversation>(
|
||||
`INSERT INTO sb_conversations (customer_id, title)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *`,
|
||||
[customerId, title ?? null]
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getConversation(
|
||||
id: string,
|
||||
customerId: string
|
||||
): Promise<Conversation | null> {
|
||||
return queryOne<Conversation>(
|
||||
`SELECT * FROM sb_conversations WHERE id = $1 AND customer_id = $2`,
|
||||
[id, customerId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteConversation(
|
||||
id: string,
|
||||
customerId: string
|
||||
): Promise<boolean> {
|
||||
const { rowCount } = await (await import("./pool.js")).pool.query(
|
||||
`DELETE FROM sb_conversations WHERE id = $1 AND customer_id = $2`,
|
||||
[id, customerId]
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function getMessages(conversationId: string): Promise<Message[]> {
|
||||
return query<Message>(
|
||||
`SELECT id, conversation_id, role, content, response_id, created_at
|
||||
FROM sb_messages
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY created_at`,
|
||||
[conversationId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function appendMessage(params: {
|
||||
conversationId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
responseId?: string;
|
||||
}): Promise<Message> {
|
||||
const [msg] = await query<Message>(
|
||||
`INSERT INTO sb_messages (conversation_id, role, content, response_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[params.conversationId, params.role, JSON.stringify(params.content), params.responseId ?? null]
|
||||
);
|
||||
|
||||
// Touch updated_at on the conversation
|
||||
await query(
|
||||
`UPDATE sb_conversations SET updated_at = NOW() WHERE id = $1`,
|
||||
[params.conversationId]
|
||||
);
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
export async function updateConversationTitle(
|
||||
id: string,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
await query(
|
||||
`UPDATE sb_conversations SET title = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[title, id]
|
||||
);
|
||||
}
|
||||
69
src/db/migrate.ts
Normal file
69
src/db/migrate.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Run this once to create the agiliton-account tables in the agiliton DB.
|
||||
* Usage: npm run migrate
|
||||
*/
|
||||
import { pool } from "./pool.js";
|
||||
|
||||
const MIGRATION = `
|
||||
-- Customers (one row per SSO identity)
|
||||
CREATE TABLE IF NOT EXISTS sb_customers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
sso_provider TEXT NOT NULL, -- 'google' | 'microsoft'
|
||||
sso_sub TEXT NOT NULL, -- provider subject id
|
||||
tier TEXT NOT NULL DEFAULT 'free',
|
||||
system_prompt TEXT,
|
||||
preferred_model TEXT,
|
||||
litellm_key_alias TEXT NOT NULL, -- e.g. sb-<customer_id>
|
||||
litellm_key_encrypted BYTEA NOT NULL,-- virtual key, AES-256-GCM encrypted
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ, -- soft delete for GDPR grace period
|
||||
UNIQUE (sso_provider, sso_sub)
|
||||
);
|
||||
|
||||
-- Conversations (per customer)
|
||||
CREATE TABLE IF NOT EXISTS sb_conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID NOT NULL REFERENCES sb_customers(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sb_conv_customer
|
||||
ON sb_conversations (customer_id, updated_at DESC);
|
||||
|
||||
-- Messages (per conversation)
|
||||
CREATE TABLE IF NOT EXISTS sb_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES sb_conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- 'user' | 'assistant' | 'tool'
|
||||
content JSONB NOT NULL, -- full OpenAI-style content blocks
|
||||
response_id TEXT, -- LiteLLM /v1/responses id for threading
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sb_msg_conv
|
||||
ON sb_messages (conversation_id, created_at);
|
||||
`;
|
||||
|
||||
async function migrate() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query(MIGRATION);
|
||||
await client.query("COMMIT");
|
||||
console.log("[migrate] done");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("[migrate] failed", err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
24
src/db/pool.ts
Normal file
24
src/db/pool.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Pool } from "pg";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export const pool = new Pool({ connectionString: config.dbUrl });
|
||||
|
||||
pool.on("error", (err) => {
|
||||
console.error("[db] unexpected pool error", err);
|
||||
});
|
||||
|
||||
export async function query<T extends object = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: unknown[]
|
||||
): Promise<T[]> {
|
||||
const { rows } = await pool.query<T>(sql, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function queryOne<T extends object = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: unknown[]
|
||||
): Promise<T | null> {
|
||||
const rows = await query<T>(sql, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
64
src/index.ts
Normal file
64
src/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import jwt from "@fastify/jwt";
|
||||
import websocket from "@fastify/websocket";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import { pool } from "./db/pool.js";
|
||||
import { ssoRoutes } from "./routes/sso.js";
|
||||
import { meRoutes } from "./routes/me.js";
|
||||
import { conversationRoutes } from "./routes/conversations.js";
|
||||
import { mcpBridgeRoute } from "./routes/mcpBridge.js";
|
||||
import { mcpDemuxRoutes } from "./routes/mcpDemux.js";
|
||||
import { getBridgeStats } from "./mcp/bridge.js";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
transport:
|
||||
process.env.NODE_ENV !== "production"
|
||||
? { target: "pino-pretty", options: { colorize: true } }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: [
|
||||
// Allow chrome-extension:// origins from the SiteBridge extension
|
||||
/^chrome-extension:\/\//,
|
||||
// Allow agiliton.cloud subdomains for future web UI
|
||||
/^https:\/\/.*\.agiliton\.cloud$/,
|
||||
"https://agiliton.eu",
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(jwt, { secret: config.jwtSecret });
|
||||
await app.register(websocket);
|
||||
|
||||
// Routes
|
||||
await app.register(ssoRoutes);
|
||||
await app.register(meRoutes);
|
||||
await app.register(conversationRoutes);
|
||||
await app.register(mcpBridgeRoute);
|
||||
await app.register(mcpDemuxRoutes);
|
||||
|
||||
// Health check
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
bridge: getBridgeStats(),
|
||||
}));
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log("[agiliton-account] shutting down...");
|
||||
await app.close();
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
await app.listen({ port: config.port, host: config.host });
|
||||
console.log(`[agiliton-account] listening on ${config.host}:${config.port}`);
|
||||
124
src/mcp/bridge.ts
Normal file
124
src/mcp/bridge.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* WebSocket MCP bridge.
|
||||
*
|
||||
* Each authenticated extension connects here. We maintain a map of
|
||||
* customer_id → WebSocket. When LiteLLM fires an MCP tool call via
|
||||
* POST /mcp/demux/:customer_id/mcp, we forward the JSON-RPC request
|
||||
* over the customer's WebSocket and await the result within a timeout.
|
||||
*
|
||||
* Protocol:
|
||||
* LiteLLM → agiliton-account → (WS) → SiteBridge extension
|
||||
* SiteBridge extension → (WS) → agiliton-account → LiteLLM
|
||||
*
|
||||
* Messages on the WebSocket are JSON-RPC 2.0 objects.
|
||||
*/
|
||||
|
||||
import type { WebSocket } from "@fastify/websocket";
|
||||
|
||||
interface PendingCall {
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
// In-memory map: customer_id → ws + pending calls
|
||||
const sessions = new Map<
|
||||
string,
|
||||
{ ws: WebSocket; pending: Map<string | number, PendingCall> }
|
||||
>();
|
||||
|
||||
export function registerCustomerWs(customerId: string, ws: WebSocket): void {
|
||||
// Clean up any stale session for this customer
|
||||
const existing = sessions.get(customerId);
|
||||
if (existing) {
|
||||
console.log(`[mcp-bridge] replacing stale ws for customer=${customerId}`);
|
||||
try { existing.ws.close(); } catch {}
|
||||
existing.pending.forEach((p) => p.reject(new Error("WebSocket replaced")));
|
||||
}
|
||||
|
||||
const pending = new Map<string | number, PendingCall>();
|
||||
sessions.set(customerId, { ws, pending });
|
||||
console.log(`[mcp-bridge] customer=${customerId} connected (total=${sessions.size})`);
|
||||
|
||||
ws.on("message", (raw: Buffer | string) => {
|
||||
let msg: { id?: string | number; result?: unknown; error?: unknown };
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
console.warn(`[mcp-bridge] customer=${customerId} sent unparseable message`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.id !== undefined) {
|
||||
const call = pending.get(msg.id);
|
||||
if (call) {
|
||||
clearTimeout(call.timer);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) {
|
||||
call.reject(new Error(JSON.stringify(msg.error)));
|
||||
} else {
|
||||
call.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
const session = sessions.get(customerId);
|
||||
if (session?.ws === ws) {
|
||||
sessions.delete(customerId);
|
||||
session!.pending.forEach((p) => p.reject(new Error("WebSocket closed")));
|
||||
console.log(`[mcp-bridge] customer=${customerId} disconnected (total=${sessions.size})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isCustomerOnline(customerId: string): boolean {
|
||||
return sessions.has(customerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a JSON-RPC MCP call to the customer's WebSocket and return the result.
|
||||
* Throws if customer is offline or times out.
|
||||
*/
|
||||
export async function forwardMcpCall(
|
||||
customerId: string,
|
||||
request: { method: string; params?: unknown; id: string | number },
|
||||
timeoutMs: number
|
||||
): Promise<unknown> {
|
||||
const session = sessions.get(customerId);
|
||||
if (!session) {
|
||||
throw new McpBridgeError("tool_unavailable: customer not online", -32001);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
session.pending.delete(request.id);
|
||||
reject(new McpBridgeError(`tool_unavailable: timeout after ${timeoutMs}ms`, -32002));
|
||||
}, timeoutMs);
|
||||
|
||||
session.pending.set(request.id, { resolve, reject, timer });
|
||||
|
||||
try {
|
||||
session.ws.send(JSON.stringify({ jsonrpc: "2.0", ...request }));
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
session.pending.delete(request.id);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class McpBridgeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: number = -32000
|
||||
) {
|
||||
super(message);
|
||||
this.name = "McpBridgeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getBridgeStats() {
|
||||
return { online: sessions.size };
|
||||
}
|
||||
27
src/middleware/auth.ts
Normal file
27
src/middleware/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||
import { getCustomerById } from "../auth/customers.js";
|
||||
import type { Customer } from "../auth/customers.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
customer?: Customer;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
// @fastify/jwt decorates verify on the request
|
||||
await req.jwtVerify();
|
||||
const payload = req.user as { sub: string };
|
||||
const customer = await getCustomerById(payload.sub);
|
||||
if (!customer) {
|
||||
return reply.code(401).send({ error: "customer_not_found" });
|
||||
}
|
||||
req.customer = customer;
|
||||
} catch {
|
||||
return reply.code(401).send({ error: "unauthorized" });
|
||||
}
|
||||
}
|
||||
95
src/routes/conversations.ts
Normal file
95
src/routes/conversations.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import {
|
||||
listConversations,
|
||||
createConversation,
|
||||
getConversation,
|
||||
getMessages,
|
||||
appendMessage,
|
||||
deleteConversation,
|
||||
} from "../db/conversations.js";
|
||||
|
||||
export async function conversationRoutes(app: FastifyInstance) {
|
||||
app.addHook("onRequest", requireAuth);
|
||||
|
||||
/** GET /v1/conversations?limit=50&offset=0 */
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||
"/v1/conversations",
|
||||
async (req) => {
|
||||
const limit = Math.min(parseInt(req.query.limit ?? "50"), 200);
|
||||
const offset = parseInt(req.query.offset ?? "0");
|
||||
return listConversations(req.customer!.id, limit, offset);
|
||||
}
|
||||
);
|
||||
|
||||
/** POST /v1/conversations */
|
||||
app.post<{ Body: { title?: string } }>(
|
||||
"/v1/conversations",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: { title: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
const conv = await createConversation(req.customer!.id, req.body.title);
|
||||
return reply.code(201).send(conv);
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /v1/conversations/:id */
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/v1/conversations/:id",
|
||||
async (req, reply) => {
|
||||
const conv = await getConversation(req.params.id, req.customer!.id);
|
||||
if (!conv) return reply.code(404).send({ error: "not_found" });
|
||||
const messages = await getMessages(req.params.id);
|
||||
return { ...conv, messages };
|
||||
}
|
||||
);
|
||||
|
||||
/** POST /v1/conversations/:id/messages */
|
||||
app.post<{
|
||||
Params: { id: string };
|
||||
Body: { role: string; content: unknown; response_id?: string };
|
||||
}>(
|
||||
"/v1/conversations/:id/messages",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["role", "content"],
|
||||
properties: {
|
||||
role: { type: "string", enum: ["user", "assistant", "tool"] },
|
||||
content: {},
|
||||
response_id: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
const conv = await getConversation(req.params.id, req.customer!.id);
|
||||
if (!conv) return reply.code(404).send({ error: "not_found" });
|
||||
|
||||
const msg = await appendMessage({
|
||||
conversationId: req.params.id,
|
||||
role: req.body.role,
|
||||
content: req.body.content,
|
||||
responseId: req.body.response_id,
|
||||
});
|
||||
return reply.code(201).send(msg);
|
||||
}
|
||||
);
|
||||
|
||||
/** DELETE /v1/conversations/:id */
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/v1/conversations/:id",
|
||||
async (req, reply) => {
|
||||
const deleted = await deleteConversation(req.params.id, req.customer!.id);
|
||||
if (!deleted) return reply.code(404).send({ error: "not_found" });
|
||||
return reply.code(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
61
src/routes/mcpBridge.ts
Normal file
61
src/routes/mcpBridge.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* WS /v1/mcp-bridge
|
||||
*
|
||||
* JWT-authenticated WebSocket endpoint. On connect, registers the customer's
|
||||
* extension as the MCP server for that customer. MCP JSON-RPC calls arrive
|
||||
* from LiteLLM via /mcp/demux/:id/mcp and are forwarded here.
|
||||
*/
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { WebSocket } from "@fastify/websocket";
|
||||
import { registerCustomerWs } from "../mcp/bridge.js";
|
||||
import { getCustomerById } from "../auth/customers.js";
|
||||
|
||||
export async function mcpBridgeRoute(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/v1/mcp-bridge",
|
||||
{ websocket: true },
|
||||
async (socket: WebSocket, req) => {
|
||||
// Verify JWT from query param or Authorization header
|
||||
// chrome.identity WebSocket handshakes can't set headers, so we also
|
||||
// accept ?token=<jwt> as a query param.
|
||||
let customerId: string | null = null;
|
||||
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const tokenParam = url.searchParams.get("token");
|
||||
|
||||
let payload: { sub: string };
|
||||
if (tokenParam) {
|
||||
payload = app.jwt.verify<{ sub: string }>(tokenParam);
|
||||
} else {
|
||||
await req.jwtVerify();
|
||||
payload = req.user as { sub: string };
|
||||
}
|
||||
|
||||
const customer = await getCustomerById(payload.sub);
|
||||
if (!customer) throw new Error("customer not found");
|
||||
customerId = customer.id;
|
||||
} catch (err) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32001, message: "unauthorized" },
|
||||
})
|
||||
);
|
||||
socket.close(1008, "unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
registerCustomerWs(customerId, socket);
|
||||
|
||||
// Send a ready signal so the extension knows the bridge is active
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "bridge/ready",
|
||||
params: { customer_id: customerId },
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
74
src/routes/mcpDemux.ts
Normal file
74
src/routes/mcpDemux.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* POST /mcp/demux/:customer_id/mcp
|
||||
*
|
||||
* Called by LiteLLM when it needs to execute a sitebridge MCP tool call.
|
||||
* Forwards the JSON-RPC request to the customer's WebSocket and returns the result.
|
||||
*
|
||||
* Authentication: shared-secret bearer token (MCP_BRIDGE_SECRET env var).
|
||||
* This endpoint is NOT exposed to customers — it's internal LiteLLM→agiliton-account only.
|
||||
*/
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { forwardMcpCall, isCustomerOnline, McpBridgeError } from "../mcp/bridge.js";
|
||||
import { config } from "../config.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export async function mcpDemuxRoutes(app: FastifyInstance) {
|
||||
app.post<{ Params: { customer_id: string } }>(
|
||||
"/mcp/demux/:customer_id/mcp",
|
||||
async (req, reply) => {
|
||||
// Verify shared secret
|
||||
const authHeader = req.headers.authorization ?? "";
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
||||
if (token !== config.mcpBridgeSecret) {
|
||||
return reply.code(401).send({ error: "unauthorized" });
|
||||
}
|
||||
|
||||
const { customer_id } = req.params;
|
||||
|
||||
if (!isCustomerOnline(customer_id)) {
|
||||
// Return a JSON-RPC error in MCP format
|
||||
return reply.code(200).send({
|
||||
jsonrpc: "2.0",
|
||||
id: (req.body as { id?: unknown })?.id ?? null,
|
||||
error: {
|
||||
code: -32001,
|
||||
message: "tool_unavailable: customer not online",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
method: string;
|
||||
params?: unknown;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
const callId = body.id ?? randomUUID();
|
||||
|
||||
try {
|
||||
const result = await forwardMcpCall(
|
||||
customer_id,
|
||||
{ method: body.method, params: body.params, id: callId },
|
||||
config.mcpBridgeTimeoutMs
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
jsonrpc: "2.0",
|
||||
id: callId,
|
||||
result,
|
||||
});
|
||||
} catch (err) {
|
||||
const code = err instanceof McpBridgeError ? err.code : -32000;
|
||||
const message = err instanceof Error ? err.message : "internal_error";
|
||||
|
||||
req.log.warn({ err, customer_id }, "MCP demux call failed");
|
||||
|
||||
return reply.code(200).send({
|
||||
jsonrpc: "2.0",
|
||||
id: callId,
|
||||
error: { code, message },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
53
src/routes/me.ts
Normal file
53
src/routes/me.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import {
|
||||
toPublic,
|
||||
updateCustomerConfig,
|
||||
softDeleteCustomer,
|
||||
exportCustomerData,
|
||||
getVirtualKey,
|
||||
} from "../auth/customers.js";
|
||||
|
||||
export async function meRoutes(app: FastifyInstance) {
|
||||
app.addHook("onRequest", requireAuth);
|
||||
|
||||
/** GET /v1/me */
|
||||
app.get("/v1/me", async (req, reply) => {
|
||||
return toPublic(req.customer!);
|
||||
});
|
||||
|
||||
/** PATCH /v1/me/config */
|
||||
app.patch<{
|
||||
Body: { system_prompt?: string | null; preferred_model?: string | null };
|
||||
}>(
|
||||
"/v1/me/config",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
system_prompt: { type: ["string", "null"] },
|
||||
preferred_model: { type: ["string", "null"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
await updateCustomerConfig(req.customer!.id, req.body);
|
||||
return reply.code(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /v1/me/export — GDPR data export */
|
||||
app.get("/v1/me/export", async (req, reply) => {
|
||||
const data = await exportCustomerData(req.customer!.id);
|
||||
reply.header("Content-Disposition", `attachment; filename="agiliton-export.json"`);
|
||||
return data;
|
||||
});
|
||||
|
||||
/** POST /v1/me/delete — GDPR right to erasure */
|
||||
app.post("/v1/me/delete", async (req, reply) => {
|
||||
await softDeleteCustomer(req.customer!.id);
|
||||
return reply.code(200).send({ deleted: true });
|
||||
});
|
||||
}
|
||||
159
src/routes/sso.ts
Normal file
159
src/routes/sso.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { exchangeGoogleCode } from "../auth/google.js";
|
||||
import { upsertCustomer, getVirtualKey } from "../auth/customers.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export async function ssoRoutes(app: FastifyInstance) {
|
||||
/**
|
||||
* POST /v1/auth/sso/google
|
||||
* Body: { code: string, redirect_uri: string }
|
||||
* Returns: { jwt, virtual_key, customer_id, is_new }
|
||||
*/
|
||||
app.post<{ Body: { code: string; redirect_uri: string } }>(
|
||||
"/v1/auth/sso/google",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["code", "redirect_uri"],
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
redirect_uri: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
let profile;
|
||||
try {
|
||||
profile = await exchangeGoogleCode(req.body.code, req.body.redirect_uri);
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, "Google OAuth exchange failed");
|
||||
return reply.code(400).send({ error: "oauth_exchange_failed" });
|
||||
}
|
||||
|
||||
const { customer, virtualKey, isNew } = await upsertCustomer({
|
||||
sso_provider: "google",
|
||||
sso_sub: profile.sub,
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
});
|
||||
|
||||
const jwt = app.jwt.sign(
|
||||
{ sub: customer.id, email: customer.email },
|
||||
{ expiresIn: config.jwtExpiresIn }
|
||||
);
|
||||
|
||||
return reply.code(isNew ? 201 : 200).send({
|
||||
jwt,
|
||||
virtual_key: virtualKey,
|
||||
customer_id: customer.id,
|
||||
is_new: isNew,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /v1/auth/sso/microsoft
|
||||
* Body: { code: string, redirect_uri: string }
|
||||
*
|
||||
* Microsoft MSAL token exchange — requires MS_CLIENT_ID + MS_TENANT_ID env vars.
|
||||
* Uses the MSAL token endpoint directly (no MSAL SDK dependency to keep bundle small).
|
||||
*/
|
||||
app.post<{ Body: { code: string; redirect_uri: string } }>(
|
||||
"/v1/auth/sso/microsoft",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["code", "redirect_uri"],
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
redirect_uri: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!config.msClientId) {
|
||||
return reply.code(501).send({ error: "microsoft_sso_not_configured" });
|
||||
}
|
||||
|
||||
let profile: { sub: string; email: string; name: string };
|
||||
try {
|
||||
profile = await exchangeMicrosoftCode(req.body.code, req.body.redirect_uri);
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, "Microsoft OAuth exchange failed");
|
||||
return reply.code(400).send({ error: "oauth_exchange_failed" });
|
||||
}
|
||||
|
||||
const { customer, virtualKey, isNew } = await upsertCustomer({
|
||||
sso_provider: "microsoft",
|
||||
sso_sub: profile.sub,
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
});
|
||||
|
||||
const jwt = app.jwt.sign(
|
||||
{ sub: customer.id, email: customer.email },
|
||||
{ expiresIn: config.jwtExpiresIn }
|
||||
);
|
||||
|
||||
return reply.code(isNew ? 201 : 200).send({
|
||||
jwt,
|
||||
virtual_key: virtualKey,
|
||||
customer_id: customer.id,
|
||||
is_new: isNew,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function exchangeMicrosoftCode(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<{ sub: string; email: string; name: string }> {
|
||||
const tenantId = config.msTenantId;
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.msClientId,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
scope: "openid email profile",
|
||||
});
|
||||
|
||||
const tokenRes = await fetch(
|
||||
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const text = await tokenRes.text();
|
||||
throw new Error(`MS token endpoint failed ${tokenRes.status}: ${text}`);
|
||||
}
|
||||
|
||||
const tokens = (await tokenRes.json()) as { id_token?: string; access_token: string };
|
||||
|
||||
// Decode the id_token (JWT) without verifying — we trust HTTPS to guarantee origin
|
||||
if (tokens.id_token) {
|
||||
const [, payload] = tokens.id_token.split(".");
|
||||
const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
|
||||
return {
|
||||
sub: claims.oid ?? claims.sub,
|
||||
email: claims.email ?? claims.preferred_username,
|
||||
name: claims.name ?? claims.email,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: call /oidc/userinfo
|
||||
const infoRes = await fetch("https://graph.microsoft.com/oidc/userinfo", {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
if (!infoRes.ok) throw new Error("MS userinfo fetch failed");
|
||||
const info = (await infoRes.json()) as { sub: string; email: string; name: string };
|
||||
return info;
|
||||
}
|
||||
26
src/utils/crypto.ts
Normal file
26
src/utils/crypto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const KEY = Buffer.from(config.encryptionKey, "hex"); // must be 32 bytes
|
||||
|
||||
export function encrypt(plaintext: string): Buffer {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGORITHM, KEY, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Layout: [12-byte IV][16-byte tag][ciphertext]
|
||||
return Buffer.concat([iv, tag, encrypted]);
|
||||
}
|
||||
|
||||
export function decrypt(buf: Buffer): string {
|
||||
const iv = buf.subarray(0, 12);
|
||||
const tag = buf.subarray(12, 28);
|
||||
const encrypted = buf.subarray(28);
|
||||
const decipher = createDecipheriv(ALGORITHM, KEY, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(encrypted) + decipher.final("utf8");
|
||||
}
|
||||
Reference in New Issue
Block a user