import { createTransportClient, READY_STATE, } from "@tensamin/ttp-core"; import { createSchema } from "./schema"; import { x448 } from "@noble/curves/ed448.js"; import Bun from "bun"; import { get } from "./user"; import { decrypt, encrypt, getSharedSecret } from "./crypto"; import { readFile } from "node:fs/promises"; /** * @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback */ export class TAuthClient { frontendUrl; identifier; privateKey; publicKey; saveSession; redirectUrl; schema; httpServer; clientMap = new Map(); appData; constructor({ frontendUrl, identifier, privateKey, publicKey, saveSession, redirectUrl, appData, httpServer, htmlSuccessPage, }) { this.frontendUrl = frontendUrl; this.identifier = identifier; this.privateKey = privateKey; this.publicKey = publicKey; if (typeof saveSession !== "function") { throw new Error("[tauth] saveSession must be provided as a function"); } this.saveSession = saveSession; this.redirectUrl = new URL(redirectUrl); this.schema = createSchema(appData); this.appData = appData; this.httpServer = httpServer ?? { port: 7878, hostname: "localhost", }; Bun.serve({ hostname: this.httpServer.hostname, port: this.httpServer.port, fetch: async (request) => { const url = new URL(request.url); switch (url.pathname) { case "/callback": { const userId = Number(url.searchParams.get("userId")); if (!userId) { return new Response("Missing userId", { status: 400 }); } const potentialChallenge = url.searchParams.get("challenge"); if (!potentialChallenge) { const challenge = await this.generateChallenge(userId); const newLink = this.generateLink(challenge); return new Response(null, { status: 302, headers: { Location: newLink, }, }); } const originalChallenge = url.searchParams.get("originalChallenge"); if (!originalChallenge) { return new Response("Missing originalChallenge", { status: 400 }); } const potentialSessionId = Number(url.searchParams.get("sessionId")); if (!potentialSessionId || isNaN(potentialSessionId)) { return new Response("Invalid or missing sessionId", { status: 400, }); } if ((await this.solveChallenge(userId, originalChallenge)) === potentialChallenge) { await this.saveSession(userId, potentialSessionId); return new Response(htmlSuccessPage || (await readFile("./index.html")), { headers: { "Content-Type": "text/html", }, status: 200, }); } else { return new Response("Failed to solve challenge", { status: 400 }); } } case "/auth": { return new Response(null, { status: 302, headers: { Location: this.generateLink(), }, }); } default: { return new Response("Not Found", { status: 404 }); } } }, }); console.log(`[tauth] HTTP Server running at http://${this.httpServer.hostname}:${this.httpServer.port}`); } async generateChallenge(userId) { const userData = await get(userId); if (!userData) { throw new Error("User not found"); } const sharedSecret = await getSharedSecret(this.privateKey, this.publicKey, userData.public_key); const encrypted = await encrypt(sharedSecret, crypto.randomUUID()); const decrypted = await decrypt(sharedSecret, encrypted); console.log(`[tauth] Generated challenge for user ${userId}: ${decrypted} (encrypted: ${encrypted})`); return Array.from(atob(encrypted)) .map((chat) => chat.charCodeAt(0).toString(16).padStart(2, "0")) .join(""); } async solveChallenge(userId, challenge) { const userData = await get(userId); if (!userData) { throw new Error("User not found"); } const sharedSecret = await getSharedSecret(this.privateKey, this.publicKey, userData.public_key); return await decrypt(sharedSecret, challenge); } createTTP(userId, sessionId, omikronUrl) { console.log(`[tauth] Creating TTP client for user ${userId} with session ${sessionId} at ${omikronUrl}`); this.clientMap.set(`${userId}:${sessionId}`, createTransportClient(this.schema, { url: omikronUrl, onReadyStateChange: (stateIndex) => { const state = getFriendlyReadyState(stateIndex); console.log(state); if (state === "OPEN") { this.clientMap .get(`${userId}:${sessionId}`) ?.send("identification", { app_identifier: this.identifier, app_session_id: sessionId, app_public_key: this.publicKey, user_id: userId, }); } }, })); this.clientMap.get(`${userId}:${sessionId}`)?.connect(); } async loadData(userId, sessionId) { const connection = await this.createTemporaryTTPConnection(userId, sessionId); const rawAppData = await connection.send("load_app_data", {}); const appData = JSON.parse(rawAppData.data.app_data); const safeAppData = this.appData.safeParse(appData); await connection.close(); if (!safeAppData.success) { throw new Error(`Invalid app data: ${JSON.stringify(safeAppData.error.issues)}`); } return safeAppData.data; } async saveData(userId, sessionId, data) { const connection = await this.createTemporaryTTPConnection(userId, sessionId); const safeData = this.appData.safeParse(data); if (!safeData.success) { throw new Error(`Invalid app data: ${JSON.stringify(safeData.error.issues)}`); } await connection.send("save_app_data", { app_data: JSON.stringify(safeData.data), }); await connection.close(); } async createTemporaryTTPConnection(userId, sessionId) { const { ip_address } = await fetch("https://omega.tensamin.net/api/get/omikron/" + userId).then((res) => res.json()); let temporaryClient; temporaryClient = createTransportClient(this.schema, { url: ip_address, onReadyStateChange: (stateIndex) => { const state = getFriendlyReadyState(stateIndex); if (state === "OPEN") { temporaryClient?.send("identification", { app_identifier: this.identifier, app_session_id: sessionId, app_public_key: this.publicKey, user_id: userId, }); } }, }); temporaryClient.connect(); return temporaryClient; } generateLink(challenge) { return new URL(`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`, this.frontendUrl).toString(); } } export function getFriendlyReadyState(stateIndex) { return Object.keys(READY_STATE).find((key) => READY_STATE[key] === stateIndex); } export function generateKeyPair() { function toBase64(bytes) { if (typeof Buffer !== "undefined") { return Buffer.from(bytes).toString("base64"); } let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary); } const priv = new Uint8Array(56); crypto.getRandomValues(priv); const pub = x448.getPublicKey(priv); return { private: toBase64(priv), public: toBase64(pub), }; } export * from "./crypto"; export * from "./schema"; export * from "./user";