tauth-sdk/dist/index.js
2026-04-14 01:43:58 +02:00

218 lines
9.6 KiB
JavaScript

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 identifier Your app's domain.
* @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback
* @param frontendUrl The URL of the frontend the user interacts with. Only used in development.
* @param appData A Zod schema defining the shape of the app data you want to load/save for each user.
* @param saveSession A function that saves the session ID for a given user ID. This is called after a successful authentication.
* @param httpServer Optional configuration for the TAuth HTTP Server. Defaults to { port: 7878, hostname: "localhost" }.
* @param htmlSuccessPage Optional HTML string or path to an HTML file to be served upon successful authentication.
*/
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";