161 lines
7 KiB
JavaScript
161 lines
7 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 redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback
|
|
*/
|
|
export class TAuthClient {
|
|
frontendUrl;
|
|
identifier;
|
|
privateKey;
|
|
publicKey;
|
|
redirectUrl;
|
|
schema;
|
|
httpServer;
|
|
clientMap = new Map();
|
|
constructor({ frontendUrl, identifier, privateKey, publicKey, redirectUrl, appData, httpServer, htmlSuccessPage, }) {
|
|
this.frontendUrl = frontendUrl;
|
|
this.identifier = identifier;
|
|
this.privateKey = privateKey;
|
|
this.publicKey = publicKey;
|
|
this.redirectUrl = new URL(redirectUrl);
|
|
this.schema = createSchema(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) {
|
|
const { ip_address } = await fetch("https://omega.tensamin.net/api/get/omikron/" + userId).then((res) => res.json());
|
|
this.createTTP(userId, potentialSessionId, ip_address);
|
|
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();
|
|
}
|
|
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),
|
|
};
|
|
}
|