(feat): better connection management, utility functions & tests
This commit is contained in:
parent
b8889812c5
commit
822dfea577
14 changed files with 448 additions and 39 deletions
19
README.md
19
README.md
|
|
@ -1,8 +1,8 @@
|
||||||
# TypeScript TAuth SDK
|
# 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
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -43,6 +43,11 @@ const client = new TAuthClient({
|
||||||
identifier: "your-app-identifier",
|
identifier: "your-app-identifier",
|
||||||
privateKey: "<APP_PRIVATE_KEY_BASE64>",
|
privateKey: "<APP_PRIVATE_KEY_BASE64>",
|
||||||
publicKey: "<APP_PUBLIC_KEY_BASE64>",
|
publicKey: "<APP_PUBLIC_KEY_BASE64>",
|
||||||
|
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",
|
redirectUrl: "http://localhost:7878/callback",
|
||||||
appData: z.object({
|
appData: z.object({
|
||||||
my: z.string(),
|
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 /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
|
These endpoints need to be exposed behind some kind of http proxy to apply ssl
|
||||||
|
|
|
||||||
55
dist/index.d.ts
vendored
55
dist/index.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
import { type TransportClient, READY_STATE } from "@tensamin/ttp-core";
|
import { type TransportClient, READY_STATE } from "@tensamin/ttp-core";
|
||||||
import { createSchema } from "./schema";
|
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
|
* @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;
|
identifier: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
|
||||||
redirectUrl: URL;
|
redirectUrl: URL;
|
||||||
schema: ReturnType<typeof createSchema>;
|
schema: ReturnType<typeof createSchema>;
|
||||||
httpServer: {
|
httpServer: {
|
||||||
|
|
@ -17,31 +18,43 @@ export declare class TAuthClient {
|
||||||
};
|
};
|
||||||
clientMap: Map<string, TransportClient<{
|
clientMap: Map<string, TransportClient<{
|
||||||
identification: {
|
identification: {
|
||||||
request: ZodObject<{
|
request: z.ZodObject<{
|
||||||
app_identifier: import("zod").ZodString;
|
app_identifier: z.ZodString;
|
||||||
app_session_id: import("zod").ZodNumber;
|
app_session_id: z.ZodNumber;
|
||||||
app_public_key: import("zod").ZodString;
|
app_public_key: z.ZodString;
|
||||||
user_id: import("zod").ZodNumber;
|
user_id: z.ZodNumber;
|
||||||
}, import("zod/v4/core").$strip>;
|
}, z.core.$strip>;
|
||||||
response: ZodObject<{
|
response: z.ZodObject<{
|
||||||
challenge: import("zod").ZodBase64;
|
challenge: z.ZodBase64;
|
||||||
public_key: import("zod").ZodBase64;
|
public_key: z.ZodBase64;
|
||||||
}, import("zod/v4/core").$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
challenge_response: {
|
challenge_response: {
|
||||||
request: ZodObject<{
|
request: z.ZodObject<{
|
||||||
challenge: import("zod").ZodBase64;
|
challenge: z.ZodBase64;
|
||||||
}, import("zod/v4/core").$strip>;
|
}, z.core.$strip>;
|
||||||
response: ZodObject<{
|
response: z.ZodObject<{}, z.core.$strip>;
|
||||||
app_data: ZodObject<import("zod/v4/core").$ZodLooseShape, import("zod/v4/core").$strip>;
|
};
|
||||||
}, import("zod/v4/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;
|
frontendUrl: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
appData: ZodObject;
|
appData: ZodObject;
|
||||||
httpServer?: {
|
httpServer?: {
|
||||||
|
|
@ -53,6 +66,9 @@ export declare class TAuthClient {
|
||||||
generateChallenge(userId: number): Promise<string>;
|
generateChallenge(userId: number): Promise<string>;
|
||||||
solveChallenge(userId: number, challenge: string): Promise<string>;
|
solveChallenge(userId: number, challenge: string): Promise<string>;
|
||||||
createTTP(userId: number, sessionId: number, omikronUrl: string): void;
|
createTTP(userId: number, sessionId: number, omikronUrl: string): void;
|
||||||
|
loadData(userId: number, sessionId: number): Promise<Record<string, unknown>>;
|
||||||
|
saveData(userId: number, sessionId: number, data: any): Promise<void>;
|
||||||
|
private createTemporaryTTPConnection;
|
||||||
generateLink(challenge?: string): string;
|
generateLink(challenge?: string): string;
|
||||||
}
|
}
|
||||||
export declare function getFriendlyReadyState(stateIndex: number): keyof typeof READY_STATE;
|
export declare function getFriendlyReadyState(stateIndex: number): keyof typeof READY_STATE;
|
||||||
|
|
@ -60,3 +76,6 @@ export declare function generateKeyPair(): {
|
||||||
private: string;
|
private: string;
|
||||||
public: string;
|
public: string;
|
||||||
};
|
};
|
||||||
|
export * from "./crypto";
|
||||||
|
export * from "./schema";
|
||||||
|
export * from "./user";
|
||||||
|
|
|
||||||
57
dist/index.js
vendored
57
dist/index.js
vendored
|
|
@ -13,17 +13,24 @@ export class TAuthClient {
|
||||||
identifier;
|
identifier;
|
||||||
privateKey;
|
privateKey;
|
||||||
publicKey;
|
publicKey;
|
||||||
|
saveSession;
|
||||||
redirectUrl;
|
redirectUrl;
|
||||||
schema;
|
schema;
|
||||||
httpServer;
|
httpServer;
|
||||||
clientMap = new Map();
|
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.frontendUrl = frontendUrl;
|
||||||
this.identifier = identifier;
|
this.identifier = identifier;
|
||||||
this.privateKey = privateKey;
|
this.privateKey = privateKey;
|
||||||
this.publicKey = publicKey;
|
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.redirectUrl = new URL(redirectUrl);
|
||||||
this.schema = createSchema(appData);
|
this.schema = createSchema(appData);
|
||||||
|
this.appData = appData;
|
||||||
this.httpServer = httpServer ?? {
|
this.httpServer = httpServer ?? {
|
||||||
port: 7878,
|
port: 7878,
|
||||||
hostname: "localhost",
|
hostname: "localhost",
|
||||||
|
|
@ -62,8 +69,7 @@ export class TAuthClient {
|
||||||
}
|
}
|
||||||
if ((await this.solveChallenge(userId, originalChallenge)) ===
|
if ((await this.solveChallenge(userId, originalChallenge)) ===
|
||||||
potentialChallenge) {
|
potentialChallenge) {
|
||||||
const { ip_address } = await fetch("https://omega.tensamin.net/api/get/omikron/" + userId).then((res) => res.json());
|
await this.saveSession(userId, potentialSessionId);
|
||||||
this.createTTP(userId, potentialSessionId, ip_address);
|
|
||||||
return new Response(htmlSuccessPage || (await readFile("./index.html")), {
|
return new Response(htmlSuccessPage || (await readFile("./index.html")), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/html",
|
"Content-Type": "text/html",
|
||||||
|
|
@ -133,6 +139,48 @@ export class TAuthClient {
|
||||||
}));
|
}));
|
||||||
this.clientMap.get(`${userId}:${sessionId}`)?.connect();
|
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) {
|
generateLink(challenge) {
|
||||||
return new URL(`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`, this.frontendUrl).toString();
|
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),
|
public: toBase64(pub),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export * from "./crypto";
|
||||||
|
export * from "./schema";
|
||||||
|
export * from "./user";
|
||||||
|
|
|
||||||
12
dist/schema.d.ts
vendored
12
dist/schema.d.ts
vendored
|
|
@ -16,8 +16,18 @@ export declare function createSchema(appData: ZodObject): {
|
||||||
request: z.ZodObject<{
|
request: z.ZodObject<{
|
||||||
challenge: z.ZodBase64;
|
challenge: z.ZodBase64;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
response: z.ZodObject<{}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
load_app_data: {
|
||||||
|
request: z.ZodObject<{}, z.core.$strip>;
|
||||||
response: z.ZodObject<{
|
response: z.ZodObject<{
|
||||||
app_data: z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>;
|
app_data: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
save_app_data: {
|
||||||
|
request: z.ZodObject<{
|
||||||
|
app_data: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
response: z.ZodObject<{}, z.core.$strip>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
12
dist/schema.js
vendored
12
dist/schema.js
vendored
|
|
@ -17,9 +17,19 @@ export function createSchema(appData) {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
challenge: z.base64(),
|
challenge: z.base64(),
|
||||||
}),
|
}),
|
||||||
|
response: z.object({}),
|
||||||
|
},
|
||||||
|
load_app_data: {
|
||||||
|
request: z.object({}),
|
||||||
response: 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({}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
dist/user.d.ts
vendored
1
dist/user.d.ts
vendored
|
|
@ -10,5 +10,4 @@ export type User = {
|
||||||
status_message: string;
|
status_message: string;
|
||||||
about: string;
|
about: string;
|
||||||
};
|
};
|
||||||
export declare const userCacheMap: Map<number, User>;
|
|
||||||
export declare function get(userId: number): Promise<User | undefined>;
|
export declare function get(userId: number): Promise<User | undefined>;
|
||||||
|
|
|
||||||
2
dist/user.js
vendored
2
dist/user.js
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
export const userCacheMap = new Map();
|
const userCacheMap = new Map();
|
||||||
export async function get(userId) {
|
export async function get(userId) {
|
||||||
try {
|
try {
|
||||||
const cachedUser = userCacheMap.get(userId);
|
const cachedUser = userCacheMap.get(userId);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@tensamin/tauth-sdk",
|
"name": "@tensamin/tauth-sdk",
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json"
|
"build": "bun run test && tsc -p tsconfig.build.json",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.12",
|
"@types/bun": "^1.3.12",
|
||||||
|
|
|
||||||
89
src/index.ts
89
src/index.ts
|
|
@ -4,7 +4,7 @@ import {
|
||||||
READY_STATE,
|
READY_STATE,
|
||||||
} from "@tensamin/ttp-core";
|
} from "@tensamin/ttp-core";
|
||||||
import { createSchema } from "./schema";
|
import { createSchema } from "./schema";
|
||||||
import type { ZodObject } from "zod";
|
import type { z, ZodObject } from "zod";
|
||||||
import { x448 } from "@noble/curves/ed448.js";
|
import { x448 } from "@noble/curves/ed448.js";
|
||||||
import Bun from "bun";
|
import Bun from "bun";
|
||||||
import { get } from "./user";
|
import { get } from "./user";
|
||||||
|
|
@ -19,6 +19,7 @@ export class TAuthClient {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
|
||||||
redirectUrl: URL;
|
redirectUrl: URL;
|
||||||
schema: ReturnType<typeof createSchema>;
|
schema: ReturnType<typeof createSchema>;
|
||||||
httpServer: {
|
httpServer: {
|
||||||
|
|
@ -26,12 +27,14 @@ export class TAuthClient {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
};
|
};
|
||||||
clientMap = new Map<string, TransportClient<typeof this.schema>>();
|
clientMap = new Map<string, TransportClient<typeof this.schema>>();
|
||||||
|
private appData: ZodObject;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
identifier,
|
identifier,
|
||||||
privateKey,
|
privateKey,
|
||||||
publicKey,
|
publicKey,
|
||||||
|
saveSession,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
appData,
|
appData,
|
||||||
httpServer,
|
httpServer,
|
||||||
|
|
@ -41,6 +44,7 @@ export class TAuthClient {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
appData: ZodObject;
|
appData: ZodObject;
|
||||||
httpServer?: {
|
httpServer?: {
|
||||||
|
|
@ -53,8 +57,13 @@ export class TAuthClient {
|
||||||
this.identifier = identifier;
|
this.identifier = identifier;
|
||||||
this.privateKey = privateKey;
|
this.privateKey = privateKey;
|
||||||
this.publicKey = publicKey;
|
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.redirectUrl = new URL(redirectUrl);
|
||||||
this.schema = createSchema(appData);
|
this.schema = createSchema(appData);
|
||||||
|
this.appData = appData;
|
||||||
this.httpServer = httpServer ?? {
|
this.httpServer = httpServer ?? {
|
||||||
port: 7878,
|
port: 7878,
|
||||||
hostname: "localhost",
|
hostname: "localhost",
|
||||||
|
|
@ -102,11 +111,7 @@ export class TAuthClient {
|
||||||
(await this.solveChallenge(userId, originalChallenge)) ===
|
(await this.solveChallenge(userId, originalChallenge)) ===
|
||||||
potentialChallenge
|
potentialChallenge
|
||||||
) {
|
) {
|
||||||
const { ip_address } = await fetch(
|
await this.saveSession(userId, potentialSessionId);
|
||||||
"https://omega.tensamin.net/api/get/omikron/" + userId,
|
|
||||||
).then((res) => res.json());
|
|
||||||
|
|
||||||
this.createTTP(userId, potentialSessionId, ip_address);
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
htmlSuccessPage || (await readFile("./index.html")),
|
htmlSuccessPage || (await readFile("./index.html")),
|
||||||
|
|
@ -210,6 +215,76 @@ export class TAuthClient {
|
||||||
this.clientMap.get(`${userId}:${sessionId}`)?.connect();
|
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<TransportClient<typeof this.schema>> {
|
||||||
|
const { ip_address } = await fetch(
|
||||||
|
"https://omega.tensamin.net/api/get/omikron/" + userId,
|
||||||
|
).then((res) => res.json());
|
||||||
|
|
||||||
|
let temporaryClient: TransportClient<typeof this.schema> | 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 {
|
generateLink(challenge?: string): string {
|
||||||
return new URL(
|
return new URL(
|
||||||
`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`,
|
`?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 "./crypto";
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,20 @@ export function createSchema(appData: ZodObject) {
|
||||||
request: z.object({
|
request: z.object({
|
||||||
challenge: z.base64(),
|
challenge: z.base64(),
|
||||||
}),
|
}),
|
||||||
|
response: z.object({}),
|
||||||
|
},
|
||||||
|
|
||||||
|
load_app_data: {
|
||||||
|
request: z.object({}),
|
||||||
response: 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({}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
test/crypto.test.ts
Normal file
62
test/crypto.test.ts
Normal file
|
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
test/index-helpers.test.ts
Normal file
39
test/index-helpers.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
39
test/schema.test.ts
Normal file
39
test/schema.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
test/user.test.ts
Normal file
82
test/user.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue