From 6f1aefc05693c63f8aed2d6c020f694f20405eff Mon Sep 17 00:00:00 2001 From: Steve Korshakov Date: Sat, 16 Aug 2025 10:13:44 -0700 Subject: [PATCH] feat: add app-to-app authentication --- .../migration.sql | 17 +++ prisma/schema.prisma | 11 ++ sources/app/api.ts | 107 ++++++++++-------- 3 files changed, 89 insertions(+), 46 deletions(-) create mode 100644 prisma/migrations/20250816171155_add_app_to_app_authentication/migration.sql diff --git a/prisma/migrations/20250816171155_add_app_to_app_authentication/migration.sql b/prisma/migrations/20250816171155_add_app_to_app_authentication/migration.sql new file mode 100644 index 0000000..14e7cd7 --- /dev/null +++ b/prisma/migrations/20250816171155_add_app_to_app_authentication/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "AccountAuthRequest" ( + "id" TEXT NOT NULL, + "publicKey" TEXT NOT NULL, + "response" TEXT, + "responseAccountId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AccountAuthRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountAuthRequest_publicKey_key" ON "AccountAuthRequest"("publicKey"); + +-- AddForeignKey +ALTER TABLE "AccountAuthRequest" ADD CONSTRAINT "AccountAuthRequest_responseAccountId_fkey" FOREIGN KEY ("responseAccountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a37a1b3..692a10e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,7 @@ model Account { Session Session[] AccountPushToken AccountPushToken[] TerminalAuthRequest TerminalAuthRequest[] + AccountAuthRequest AccountAuthRequest[] UsageReport UsageReport[] Machine Machine[] } @@ -43,6 +44,16 @@ model TerminalAuthRequest { updatedAt DateTime @updatedAt } +model AccountAuthRequest { + id String @id @default(cuid()) + publicKey String @unique + response String? + responseAccountId String? + responseAccount Account? @relation(fields: [responseAccountId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model AccountPushToken { id String @id @default(cuid()) accountId String diff --git a/sources/app/api.ts b/sources/app/api.ts index a1b7a8b..a5abadf 100644 --- a/sources/app/api.ts +++ b/sources/app/api.ts @@ -292,63 +292,78 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }> return reply.send({ success: true }); }); - // OpenAI Realtime ephemeral token generation - typed.post('/v1/openai/realtime-token', { - preHandler: app.authenticate, + // Account auth request + typed.post('/v1/auth/account/request', { schema: { + body: z.object({ + publicKey: z.string(), + }), response: { - 200: z.object({ - token: z.string() - }), - 500: z.object({ - error: z.string() + 200: z.union([z.object({ + state: z.literal('requested'), + }), z.object({ + state: z.literal('authorized'), + token: z.string(), + response: z.string() + })]), + 401: z.object({ + error: z.literal('Invalid public key') }) } } }, async (request, reply) => { - try { - // Check if OpenAI API key is configured on server - const OPENAI_API_KEY = process.env.OPENAI_API_KEY; - if (!OPENAI_API_KEY) { - return reply.code(500).send({ - error: 'OpenAI API key not configured on server' - }); - } + const publicKey = privacyKit.decodeBase64(request.body.publicKey); + const isValid = tweetnacl.box.publicKeyLength === publicKey.length; + if (!isValid) { + return reply.code(401).send({ error: 'Invalid public key' }); + } - // Generate ephemeral token from OpenAI - const response = await fetch('https://api.openai.com/v1/realtime/sessions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${OPENAI_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: 'gpt-4o-realtime-preview-2024-12-17', - voice: 'verse', - }), - }); - - if (!response.ok) { - throw new Error(`OpenAI API error: ${response.status}`); - } - - const data = await response.json() as { - client_secret: { - value: string; - expires_at: number; - }; - id: string; - }; + const answer = await db.accountAuthRequest.upsert({ + where: { publicKey: privacyKit.encodeHex(publicKey) }, + update: {}, + create: { publicKey: privacyKit.encodeHex(publicKey) } + }); + if (answer.response && answer.responseAccountId) { + const token = await tokenGenerator.new({ user: answer.responseAccountId! }); return reply.send({ - token: data.client_secret.value - }); - } catch (error) { - log({ module: 'openai', level: 'error' }, 'Failed to generate ephemeral token', error); - return reply.code(500).send({ - error: 'Failed to generate ephemeral token' + state: 'authorized', + token: token, + response: answer.response }); } + + return reply.send({ state: 'requested' }); + }); + + // Approve account auth request + typed.post('/v1/auth/account/response', { + preHandler: app.authenticate, + schema: { + body: z.object({ + response: z.string(), + publicKey: z.string() + }) + } + }, async (request, reply) => { + const publicKey = privacyKit.decodeBase64(request.body.publicKey); + const isValid = tweetnacl.box.publicKeyLength === publicKey.length; + if (!isValid) { + return reply.code(401).send({ error: 'Invalid public key' }); + } + const authRequest = await db.accountAuthRequest.findUnique({ + where: { publicKey: privacyKit.encodeHex(publicKey) } + }); + if (!authRequest) { + return reply.code(404).send({ error: 'Request not found' }); + } + if (!authRequest.response) { + await db.accountAuthRequest.update({ + where: { id: authRequest.id }, + data: { response: request.body.response, responseAccountId: request.user.id } + }); + } + return reply.send({ success: true }); }); // Sessions API