diff --git a/README.md b/README.md index a89c7e1..091b4ba 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # TypeScript TAuth SDK -TypeScript SDK for TAuth-based login and transport session bootstrap. +TypeScript SDK for TAuth-based login and explicit transport session management. -This SDK starts a local HTTP callback server, verifies a challenge, and opens an authenticated TTP transport client per `userId:sessionId`. +This SDK starts a local HTTP callback server, verifies a challenge, and gives you explicit control over TTP connections per `userId:sessionId`. ## Requirements @@ -43,6 +43,11 @@ const client = new TAuthClient({ identifier: "your-app-identifier", privateKey: "", publicKey: "", + saveSession: async (userId, sessionId) => { + // You must persist the session id yourself (for example in a database). + // Treat the session id like a password! + console.log("Save session", { userId, sessionId }); + }, redirectUrl: "http://localhost:7878/callback", appData: z.object({ my: z.string(), @@ -56,9 +61,15 @@ const client = new TAuthClient({ }); ``` -## Endpoints exposed by the SDK +## Auth endpoints exposed by the SDK - `GET /auth`: Redirects user to TAuth frontend -- `GET /callback`: Handles challenge flow and session bootstrap +- `GET /callback`: Handles challenge flow and invokes your `saveSession(userId, sessionId)` callback + +## Connection management + +- Use `client.createTTP(userId, sessionId, omikronUrl)` when you want a persistent/manual TTP connection. +- Use `client.loadData(userId, sessionId)` for loading user specific application data. +- Use `client.saveData(userId, sessionId)` for saving user specific application data. These endpoints need to be exposed behind some kind of http proxy to apply ssl diff --git a/dist/index.d.ts b/dist/index.d.ts index 20f08f8..f996ee6 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,6 +1,6 @@ import { type TransportClient, READY_STATE } from "@tensamin/ttp-core"; import { createSchema } from "./schema"; -import type { ZodObject } from "zod"; +import type { z, ZodObject } from "zod"; /** * @param redirectUrl The URL should point to the TAuth HTTP Server at http://hostname:port/callback */ @@ -9,6 +9,7 @@ export declare class TAuthClient { identifier: string; privateKey: string; publicKey: string; + saveSession: (userId: number, sessionId: number) => Promise | void; redirectUrl: URL; schema: ReturnType; httpServer: { @@ -17,31 +18,43 @@ export declare class TAuthClient { }; clientMap: Map; - response: ZodObject<{ - challenge: import("zod").ZodBase64; - public_key: import("zod").ZodBase64; - }, import("zod/v4/core").$strip>; + request: z.ZodObject<{ + app_identifier: z.ZodString; + app_session_id: z.ZodNumber; + app_public_key: z.ZodString; + user_id: z.ZodNumber; + }, z.core.$strip>; + response: z.ZodObject<{ + challenge: z.ZodBase64; + public_key: z.ZodBase64; + }, z.core.$strip>; }; challenge_response: { - request: ZodObject<{ - challenge: import("zod").ZodBase64; - }, import("zod/v4/core").$strip>; - response: ZodObject<{ - app_data: ZodObject; - }, import("zod/v4/core").$strip>; + request: z.ZodObject<{ + challenge: z.ZodBase64; + }, z.core.$strip>; + response: z.ZodObject<{}, z.core.$strip>; + }; + load_app_data: { + request: z.ZodObject<{}, z.core.$strip>; + response: z.ZodObject<{ + app_data: z.ZodString; + }, z.core.$strip>; + }; + save_app_data: { + request: z.ZodObject<{ + app_data: z.ZodString; + }, z.core.$strip>; + response: z.ZodObject<{}, z.core.$strip>; }; }>>; - constructor({ frontendUrl, identifier, privateKey, publicKey, redirectUrl, appData, httpServer, htmlSuccessPage, }: { + private appData; + constructor({ frontendUrl, identifier, privateKey, publicKey, saveSession, redirectUrl, appData, httpServer, htmlSuccessPage, }: { frontendUrl: string; identifier: string; privateKey: string; publicKey: string; + saveSession: (userId: number, sessionId: number) => Promise | void; redirectUrl: string; appData: ZodObject; httpServer?: { @@ -53,6 +66,9 @@ export declare class TAuthClient { generateChallenge(userId: number): Promise; solveChallenge(userId: number, challenge: string): Promise; createTTP(userId: number, sessionId: number, omikronUrl: string): void; + loadData(userId: number, sessionId: number): Promise>; + saveData(userId: number, sessionId: number, data: any): Promise; + private createTemporaryTTPConnection; generateLink(challenge?: string): string; } export declare function getFriendlyReadyState(stateIndex: number): keyof typeof READY_STATE; @@ -60,3 +76,6 @@ export declare function generateKeyPair(): { private: string; public: string; }; +export * from "./crypto"; +export * from "./schema"; +export * from "./user"; diff --git a/dist/index.js b/dist/index.js index 40ccccc..d09aa92 100644 --- a/dist/index.js +++ b/dist/index.js @@ -13,17 +13,24 @@ export class TAuthClient { identifier; privateKey; publicKey; + saveSession; redirectUrl; schema; httpServer; clientMap = new Map(); - constructor({ frontendUrl, identifier, privateKey, publicKey, redirectUrl, appData, httpServer, htmlSuccessPage, }) { + 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", @@ -62,8 +69,7 @@ export class TAuthClient { } 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); + await this.saveSession(userId, potentialSessionId); return new Response(htmlSuccessPage || (await readFile("./index.html")), { headers: { "Content-Type": "text/html", @@ -133,6 +139,48 @@ export class TAuthClient { })); 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(); } @@ -159,3 +207,6 @@ export function generateKeyPair() { public: toBase64(pub), }; } +export * from "./crypto"; +export * from "./schema"; +export * from "./user"; diff --git a/dist/schema.d.ts b/dist/schema.d.ts index 75cb45c..7100948 100644 --- a/dist/schema.d.ts +++ b/dist/schema.d.ts @@ -16,8 +16,18 @@ export declare function createSchema(appData: ZodObject): { request: z.ZodObject<{ challenge: z.ZodBase64; }, z.core.$strip>; + response: z.ZodObject<{}, z.core.$strip>; + }; + load_app_data: { + request: z.ZodObject<{}, z.core.$strip>; response: z.ZodObject<{ - app_data: z.ZodObject; + app_data: z.ZodString; }, z.core.$strip>; }; + save_app_data: { + request: z.ZodObject<{ + app_data: z.ZodString; + }, z.core.$strip>; + response: z.ZodObject<{}, z.core.$strip>; + }; }; diff --git a/dist/schema.js b/dist/schema.js index 90fa62a..3d2f05c 100644 --- a/dist/schema.js +++ b/dist/schema.js @@ -17,9 +17,19 @@ export function createSchema(appData) { request: z.object({ challenge: z.base64(), }), + response: z.object({}), + }, + load_app_data: { + request: z.object({}), response: z.object({ - app_data: appData, + app_data: z.string(), }), }, + save_app_data: { + request: z.object({ + app_data: z.string(), + }), + response: z.object({}), + }, }; } diff --git a/dist/user.d.ts b/dist/user.d.ts index b4a47a8..6758258 100644 --- a/dist/user.d.ts +++ b/dist/user.d.ts @@ -10,5 +10,4 @@ export type User = { status_message: string; about: string; }; -export declare const userCacheMap: Map; export declare function get(userId: number): Promise; diff --git a/dist/user.js b/dist/user.js index 2be1b4b..f017726 100644 --- a/dist/user.js +++ b/dist/user.js @@ -1,4 +1,4 @@ -export const userCacheMap = new Map(); +const userCacheMap = new Map(); export async function get(userId) { try { const cachedUser = userCacheMap.get(userId); diff --git a/package.json b/package.json index db2a14b..44ed578 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tensamin/tauth-sdk", - "version": "0.0.1", + "version": "0.0.2", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -15,7 +15,8 @@ } }, "scripts": { - "build": "tsc -p tsconfig.build.json" + "build": "bun run test && tsc -p tsconfig.build.json", + "test": "bun test" }, "devDependencies": { "@types/bun": "^1.3.12", diff --git a/src/index.ts b/src/index.ts index d5a70ba..afa19ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { READY_STATE, } from "@tensamin/ttp-core"; import { createSchema } from "./schema"; -import type { ZodObject } from "zod"; +import type { z, ZodObject } from "zod"; import { x448 } from "@noble/curves/ed448.js"; import Bun from "bun"; import { get } from "./user"; @@ -19,6 +19,7 @@ export class TAuthClient { identifier: string; privateKey: string; publicKey: string; + saveSession: (userId: number, sessionId: number) => Promise | void; redirectUrl: URL; schema: ReturnType; httpServer: { @@ -26,12 +27,14 @@ export class TAuthClient { hostname: string; }; clientMap = new Map>(); + private appData: ZodObject; constructor({ frontendUrl, identifier, privateKey, publicKey, + saveSession, redirectUrl, appData, httpServer, @@ -41,6 +44,7 @@ export class TAuthClient { identifier: string; privateKey: string; publicKey: string; + saveSession: (userId: number, sessionId: number) => Promise | void; redirectUrl: string; appData: ZodObject; httpServer?: { @@ -53,8 +57,13 @@ export class TAuthClient { 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", @@ -102,11 +111,7 @@ export class TAuthClient { (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); + await this.saveSession(userId, potentialSessionId); return new Response( htmlSuccessPage || (await readFile("./index.html")), @@ -210,6 +215,76 @@ export class TAuthClient { this.clientMap.get(`${userId}:${sessionId}`)?.connect(); } + async loadData(userId: number, sessionId: number) { + 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: number, sessionId: number, data: any) { + 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(); + } + + private async createTemporaryTTPConnection( + userId: number, + sessionId: number, + ): Promise> { + const { ip_address } = await fetch( + "https://omega.tensamin.net/api/get/omikron/" + userId, + ).then((res) => res.json()); + + let temporaryClient: TransportClient | undefined; + 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?: string): string { return new URL( `?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`, @@ -251,4 +326,4 @@ export function generateKeyPair(): { private: string; public: string } { export * from "./crypto"; export * from "./schema"; -export * from "./user"; \ No newline at end of file +export * from "./user"; diff --git a/src/schema.ts b/src/schema.ts index dc6296c..01339db 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -18,9 +18,20 @@ export function createSchema(appData: ZodObject) { request: z.object({ challenge: z.base64(), }), + response: z.object({}), + }, + + load_app_data: { + request: z.object({}), response: z.object({ - app_data: appData, + app_data: z.string(), }), }, + save_app_data: { + request: z.object({ + app_data: z.string(), + }), + response: z.object({}), + }, }; } diff --git a/test/crypto.test.ts b/test/crypto.test.ts new file mode 100644 index 0000000..9b7fa02 --- /dev/null +++ b/test/crypto.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { x448 } from "@noble/curves/ed448.js"; +import { decrypt, encrypt, getSharedSecret } from "../src/crypto"; + +function toBase64(bytes: Uint8Array): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +describe("crypto", () => { + test("encrypt/decrypt roundtrip", async () => { + const privA = new Uint8Array(56); + crypto.getRandomValues(privA); + const pubA = x448.getPublicKey(privA); + + const privB = new Uint8Array(56); + crypto.getRandomValues(privB); + const pubB = x448.getPublicKey(privB); + + const sharedA = await getSharedSecret( + toBase64(privA), + toBase64(pubA), + toBase64(pubB), + ); + + const sharedB = await getSharedSecret( + toBase64(privB), + toBase64(pubB), + toBase64(pubA), + ); + + expect(sharedA).toBe(sharedB); + + const message = "hello tauth sdk"; + const cipher = await encrypt(sharedA, message); + const plain = await decrypt(sharedB, cipher); + + expect(plain).toBe(message); + }); + + test("decrypt fails when using wrong shared secret", async () => { + const secretA = "01".repeat(56); + const secretB = "02".repeat(56); + + const cipher = await encrypt(secretA, "sensitive"); + + await expect(decrypt(secretB, cipher)).rejects.toThrow(); + }); + + test("getSharedSecret rejects malformed key material", async () => { + await expect(getSharedSecret("AA==", "AA==", "AA==")).rejects.toThrow( + "not a valid X448", + ); + }); +}); diff --git a/test/index-helpers.test.ts b/test/index-helpers.test.ts new file mode 100644 index 0000000..18c93af --- /dev/null +++ b/test/index-helpers.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { READY_STATE } from "@tensamin/ttp-core"; +import { TAuthClient, generateKeyPair, getFriendlyReadyState } from "../src/index"; + +describe("index helpers", () => { + test("generateKeyPair returns base64 keys with expected length", () => { + const pair = generateKeyPair(); + const priv = Buffer.from(pair.private, "base64"); + const pub = Buffer.from(pair.public, "base64"); + + expect(priv.length).toBe(56); + expect(pub.length).toBe(56); + }); + + test("getFriendlyReadyState maps numeric state to key", () => { + for (const [name, value] of Object.entries(READY_STATE)) { + expect(getFriendlyReadyState(value)).toBe(name); + } + }); + + test("generateLink builds auth URL with and without challenge", () => { + const client = Object.create(TAuthClient.prototype) as TAuthClient; + client.frontendUrl = "https://tauth.example.com/login"; + client.identifier = "my-app"; + client.redirectUrl = new URL("https://app.example.com/callback"); + + const withoutChallenge = client.generateLink(); + const withChallenge = client.generateLink("abcdef"); + + const parsedWithout = new URL(withoutChallenge); + const parsedWith = new URL(withChallenge); + + expect(parsedWithout.searchParams.get("identifier")).toBe("my-app"); + expect(parsedWithout.searchParams.get("redirect")).toBe( + "https://app.example.com/callback", + ); + expect(parsedWith.searchParams.get("challenge")).toBe("abcdef"); + }); +}); diff --git a/test/schema.test.ts b/test/schema.test.ts new file mode 100644 index 0000000..a574b41 --- /dev/null +++ b/test/schema.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import z from "zod"; +import { createSchema } from "../src/schema"; + +describe("schema", () => { + const appData = z.object({ + my: z.string(), + cool: z.string(), + }); + + const schema = createSchema(appData); + + test("accepts valid identification request", () => { + const parsed = schema.identification.request.parse({ + app_identifier: "app-1", + app_session_id: 42, + app_public_key: "base64key", + user_id: 123, + }); + + expect(parsed.user_id).toBe(123); + }); + + test("rejects invalid challenge response request", () => { + const result = schema.challenge_response.request.safeParse({ + challenge: "not-base64", + }); + + expect(result.success).toBe(false); + }); + + test("accepts valid save_app_data request", () => { + const result = schema.save_app_data.request.safeParse({ + app_data: JSON.stringify({ my: "a", cool: "b" }), + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/test/user.test.ts b/test/user.test.ts new file mode 100644 index 0000000..db2c1e3 --- /dev/null +++ b/test/user.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { get } from "../src/user"; + +describe("user.get", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + // Reset mocks each test and isolate IDs to avoid cache collisions. + mock.restore(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("fetches and returns user data", async () => { + const fetchMock = mock(async () => { + return { + json: async () => ({ + status: "ok", + username: "alice", + public_key: "pub", + user_id: 9001, + iota_id: 1, + sub_level: 0, + sub_end: 0, + display: "Alice", + status_message: "hi", + about: "about", + }), + } as Response; + }); + + globalThis.fetch = fetchMock as typeof fetch; + + const result = await get(9001); + + expect(result?.username).toBe("alice"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("returns cached value for repeated user id", async () => { + const fetchMock = mock(async () => { + return { + json: async () => ({ + status: "ok", + username: "bob", + public_key: "pub2", + user_id: 9002, + iota_id: 1, + sub_level: 0, + sub_end: 0, + display: "Bob", + status_message: "hi", + about: "about", + }), + } as Response; + }); + + globalThis.fetch = fetchMock as typeof fetch; + + const first = await get(9002); + const second = await get(9002); + + expect(first?.username).toBe("bob"); + expect(second?.username).toBe("bob"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("returns undefined when fetch fails", async () => { + const fetchMock = mock(async () => { + throw new Error("network down"); + }); + + globalThis.fetch = fetchMock as typeof fetch; + + const result = await get(9003); + + expect(result).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +});