(feat): migrate to rust
This commit is contained in:
parent
788f30eea4
commit
20b12f3c60
28 changed files with 3365 additions and 2775 deletions
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "type-maps.yaml", relative = true }
|
||||
2654
Cargo.lock
generated
Normal file
2654
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "tauth-sdk"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "UNLICENSED"
|
||||
|
||||
[lib]
|
||||
name = "tauth_sdk"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
mtp = { git = "https://git.methanium.net/methanium/mtp.git", features = ["client", "crypto"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
244
README.md
244
README.md
|
|
@ -1,74 +1,190 @@
|
|||
# TypeScript TAuth SDK
|
||||
# TAuth SDK
|
||||
|
||||
TypeScript SDK for TAuth-based login and explicit transport session management.
|
||||
Rust library for integrating applications with TAuth and MTP.
|
||||
|
||||
This SDK starts a local HTTP callback server, verifies a challenge, and gives you explicit control over TTP connections per `userId:sessionId`.
|
||||
The SDK handles app key material, TAuth login URLs, callback parsing, Omikron lookup, authenticated MTP connections, and loading/saving user-specific app data.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A modern version of Bun
|
||||
- A domain with TXT record support
|
||||
- App x448 key pair (`privateKey`, `publicKey`) (can be generated)
|
||||
- Rust 1.95+
|
||||
- A TAuth app identifier, usually your app domain
|
||||
- An MTP key pair generated by this library
|
||||
- A public callback URL in your application
|
||||
|
||||
## Install
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
tauth-sdk = { git = "https://git.methanium.net/tensamin/tauth-sdk.git" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
```
|
||||
|
||||
## Generate App Keys
|
||||
|
||||
Run this once, store the private key securely, and publish the public key where TAuth expects app verification data.
|
||||
|
||||
```rust
|
||||
use tauth_sdk::generate_key_pair;
|
||||
|
||||
fn main() {
|
||||
let keys = generate_key_pair();
|
||||
|
||||
println!("private: {}", keys.private);
|
||||
println!("public: {}", keys.public);
|
||||
}
|
||||
```
|
||||
|
||||
Keys are base64-encoded MTP key material. Do not expose the private key.
|
||||
|
||||
## Create A Client
|
||||
|
||||
```rust
|
||||
use tauth_sdk::{TAuthClient, TAuthConfig};
|
||||
|
||||
let client = TAuthClient::from_config(
|
||||
TAuthConfig::new(
|
||||
"my.cool.app",
|
||||
"https://my.cool.app/tauth/callback",
|
||||
std::env::var("TAUTH_PRIVATE_KEY")?,
|
||||
)
|
||||
.frontend_url("https://tauth.tensamin.net/login"),
|
||||
)?;
|
||||
```
|
||||
|
||||
For the default TAuth frontend, this shorter form is equivalent:
|
||||
|
||||
```rust
|
||||
let client = TAuthClient::new(
|
||||
"my.cool.app",
|
||||
"https://my.cool.app/tauth/callback",
|
||||
std::env::var("TAUTH_PRIVATE_KEY")?,
|
||||
)?;
|
||||
```
|
||||
|
||||
## Start Login
|
||||
|
||||
Redirect the user to `client.auth_url(None)` from your web handler.
|
||||
|
||||
```rust
|
||||
let login_url = client.auth_url(None);
|
||||
```
|
||||
|
||||
The URL includes:
|
||||
|
||||
- `identifier`: your app identifier
|
||||
- `redirect`: your callback URL
|
||||
- `public_key`: your app MTP public key bundle
|
||||
- `challenge`: optional caller-supplied challenge
|
||||
|
||||
## Handle Callback
|
||||
|
||||
In your callback route, parse the full callback URL and persist the returned `user_id` and `session_id` for your user.
|
||||
|
||||
```rust
|
||||
let callback = client.parse_callback(full_callback_url)?;
|
||||
|
||||
save_session(callback.user_id, callback.session_id).await?;
|
||||
```
|
||||
|
||||
`AuthCallback` contains:
|
||||
|
||||
```rust
|
||||
pub struct AuthCallback {
|
||||
pub user_id: u64,
|
||||
pub session_id: u64,
|
||||
pub challenge: Option<String>,
|
||||
pub original_challenge: Option<String>,
|
||||
pub host_public_key: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
## Connect To TAuth Data Over MTP
|
||||
|
||||
If the callback or your application already has the Omikron host public key, pass it explicitly:
|
||||
|
||||
```rust
|
||||
let session = client
|
||||
.connect_session(callback.user_id, callback.session_id, host_public_key)
|
||||
.await?;
|
||||
```
|
||||
|
||||
If the Omikron API returns a `public_key`, the SDK can look it up automatically:
|
||||
|
||||
```rust
|
||||
let session = client.session_from_callback(&callback).await?;
|
||||
```
|
||||
|
||||
The connection uses MTP authenticated login with your app keyring and the Omikron host public key.
|
||||
|
||||
## Load And Save App Data
|
||||
|
||||
Use serde types for application data:
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AppData {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
let data: AppData = session.load().await?;
|
||||
|
||||
session
|
||||
.save(&AppData {
|
||||
name: "example".into(),
|
||||
value: "updated".into(),
|
||||
})
|
||||
.await?;
|
||||
```
|
||||
|
||||
Raw JSON is also supported:
|
||||
|
||||
```rust
|
||||
let data = session.load_json().await?;
|
||||
session.save_json(&data).await?;
|
||||
```
|
||||
|
||||
## Other Helpers
|
||||
|
||||
Fetch a TAuth user profile:
|
||||
|
||||
```rust
|
||||
let user = client.user(callback.user_id).await?;
|
||||
```
|
||||
|
||||
Connect directly to a known Omikron URL:
|
||||
|
||||
```rust
|
||||
let connection = client
|
||||
.connect_to_omikron(user_id, session_id, "https://omikron.example:4433", host_public_key)
|
||||
.await?;
|
||||
```
|
||||
|
||||
Use lower-level MTP connection helpers when needed:
|
||||
|
||||
```rust
|
||||
let value = tauth_sdk::load_json(&connection).await?;
|
||||
tauth_sdk::save_json(&connection, &value).await?;
|
||||
```
|
||||
|
||||
## MTP Type Map
|
||||
|
||||
This crate compiles an MTP type map for TAuth app data messages:
|
||||
|
||||
- `LoadAppData`
|
||||
- `LoadAppDataResponse`
|
||||
- `SaveAppData`
|
||||
- `SaveAppDataResponse`
|
||||
- `AppData`
|
||||
|
||||
The map lives in `type-maps.yaml` and is wired through `.cargo/config.toml` using `MTP_TYPE_MAPS`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun add https://git.methanium.net/tensamin/tauth-sdk/archive/0.0.3.tar.gz
|
||||
cargo test
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Generate an app key pair
|
||||
|
||||
Use the built-in helper once and store the keys securely:
|
||||
|
||||
```ts
|
||||
import { generateKeyPair } from "@tensamin/tauth-sdk";
|
||||
|
||||
const keys = generateKeyPair();
|
||||
console.log(keys.private);
|
||||
console.log(keys.public);
|
||||
```
|
||||
|
||||
## Add the TXT record
|
||||
|
||||
Add a TXT record at `tauth.your.domain` with your app public key as the value (base64 format). This is used to verify that your app is authorized for your domain.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { TAuthClient } from "@tensamin/tauth-sdk";
|
||||
|
||||
const client = new TAuthClient({
|
||||
identifier: "my.cool.app",
|
||||
privateKey: "<APP_PRIVATE_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: "https://my.cool.app/callback", // This should be a public link proxying http://localhost:7878/callback
|
||||
appData: z.object({
|
||||
my: z.string(),
|
||||
cool: z.string(),
|
||||
data: z.string(),
|
||||
}),
|
||||
httpServer: {
|
||||
hostname: "localhost",
|
||||
port: 7878,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Auth endpoints exposed by the SDK
|
||||
|
||||
- `GET /auth`: Redirects user to TAuth frontend
|
||||
- `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
|
||||
|
|
|
|||
219
bun.lock
219
bun.lock
|
|
@ -1,219 +0,0 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tauth-sdk",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.9.tar.gz",
|
||||
"@webtransport-bun/webtransport": "^0.3.0",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/node": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
||||
|
||||
"@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.9.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.58.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.2", "typescript-eslint": "^8.58.1", "zod": "^4.3.6" } }, "sha512-V9EK/t/tcBe1oNqzUMxtn41wc3UIS0mkLvGeR1mGfZaKn4d9bwwWQ2VmSmRSxodPYiQUJkziJV4fZvdzGjnzFw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
|
||||
|
||||
"@webtransport-bun/webtransport": ["@webtransport-bun/webtransport@0.3.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-/OX/TCgBD64n/0BjMNytObq5NK2pAM0KOoXBEw49l3sTmbVXNTPP1ZRPIdIyV43agM+mKtZBaV3Er3k3YoB6sw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.58.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.1", "@typescript-eslint/parser": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@tensamin/ttp-core/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
}
|
||||
}
|
||||
18
dist/crypto.d.ts
vendored
18
dist/crypto.d.ts
vendored
|
|
@ -1,18 +0,0 @@
|
|||
type Base64URLString = string;
|
||||
export declare function encrypt(secret: string, plaintext: string): Promise<string>;
|
||||
/**
|
||||
* Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param ciphertext Base64 ciphertext to decrypt.
|
||||
* @returns Decrypted UTF-8 plaintext.
|
||||
*/
|
||||
export declare function decrypt(secret: string, ciphertext: Base64URLString | string): Promise<string>;
|
||||
/**
|
||||
* Computes an X448 shared secret from local and peer key material.
|
||||
* @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form.
|
||||
* @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form.
|
||||
* @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form.
|
||||
* @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid.
|
||||
*/
|
||||
export declare function getSharedSecret(ownPrivateKey: string, ownPublicKey: string, otherPublicKey: string): Promise<string>;
|
||||
export {};
|
||||
331
dist/crypto.js
vendored
331
dist/crypto.js
vendored
|
|
@ -1,331 +0,0 @@
|
|||
const textEncoder = new TextEncoder();
|
||||
const crypto = globalThis.crypto;
|
||||
export async function encrypt(secret, plaintext) {
|
||||
const sharedSecret = new Uint8Array(secret.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
|
||||
const hkdfKey = await crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, ["deriveBits"]);
|
||||
const okm = await crypto.subtle.deriveBits({
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array([]),
|
||||
info: textEncoder.encode("x448-aes-gcm-no-overhead"),
|
||||
}, hkdfKey, 44 * 8);
|
||||
const okmBytes = new Uint8Array(okm);
|
||||
const keyBytes = okmBytes.slice(0, 32);
|
||||
const nonce = okmBytes.slice(32, 44);
|
||||
const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"]);
|
||||
const encryptedBuffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, aesKey, textEncoder.encode(plaintext));
|
||||
return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer)));
|
||||
}
|
||||
/**
|
||||
* Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param ciphertext Base64 ciphertext to decrypt.
|
||||
* @returns Decrypted UTF-8 plaintext.
|
||||
*/
|
||||
export async function decrypt(secret, ciphertext) {
|
||||
const sharedSecret = new Uint8Array(secret.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
|
||||
const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
|
||||
const hkdfKey = await crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, ["deriveBits"]);
|
||||
const okm = await crypto.subtle.deriveBits({
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array([]),
|
||||
info: textEncoder.encode("x448-aes-gcm-no-overhead"),
|
||||
}, hkdfKey, 44 * 8);
|
||||
const okmBytes = new Uint8Array(okm);
|
||||
const keyBytes = okmBytes.slice(0, 32);
|
||||
const nonce = okmBytes.slice(32, 44);
|
||||
const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
|
||||
const decryptedBuffer = await crypto.subtle.decrypt({
|
||||
name: "AES-GCM",
|
||||
iv: nonce,
|
||||
}, aesKey, ciphertextBytes);
|
||||
return new TextDecoder().decode(decryptedBuffer);
|
||||
}
|
||||
/**
|
||||
* Computes an X448 shared secret from local and peer key material.
|
||||
* @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form.
|
||||
* @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form.
|
||||
* @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form.
|
||||
* @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid.
|
||||
*/
|
||||
export async function getSharedSecret(ownPrivateKey, ownPublicKey, otherPublicKey) {
|
||||
const otherJwk = { kty: "OKP", crv: "X448", x: otherPublicKey };
|
||||
const ownJwk = {
|
||||
kty: "OKP",
|
||||
crv: "X448",
|
||||
x: ownPublicKey,
|
||||
d: ownPrivateKey,
|
||||
};
|
||||
/**
|
||||
* Converts bytes to a lowercase hex string.
|
||||
* @param u8 Byte array.
|
||||
* @returns Hex string.
|
||||
*/
|
||||
const bytesToHex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
/**
|
||||
* Decodes standard base64 text into bytes.
|
||||
* @param s Base64 string.
|
||||
* @returns Decoded bytes.
|
||||
*/
|
||||
const b64ToBytes = (s) => {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++)
|
||||
out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* Decodes URL-safe base64 text into bytes.
|
||||
* @param s Base64url string.
|
||||
* @returns Decoded bytes.
|
||||
*/
|
||||
const b64uToBytes = (s) => {
|
||||
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
|
||||
return b64ToBytes(b64);
|
||||
};
|
||||
/**
|
||||
* Encodes bytes as URL-safe base64 without padding.
|
||||
* @param u8 Byte array.
|
||||
* @returns Base64url string.
|
||||
*/
|
||||
const bytesToB64u = (u8) => {
|
||||
const b64 = btoa(String.fromCharCode(...u8));
|
||||
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
};
|
||||
/**
|
||||
* Decodes either base64 or base64url text into bytes.
|
||||
* @param s Base64/base64url string.
|
||||
* @returns Decoded bytes.
|
||||
*/
|
||||
const decodeBase64Auto = (s) => /[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s);
|
||||
/**
|
||||
* Reads a DER TLV item from the provided offset.
|
||||
* @param view DER-encoded bytes.
|
||||
* @param off Start offset.
|
||||
* @returns Parsed TLV metadata with tag, length, and boundaries.
|
||||
*/
|
||||
const readTLV = (view, off) => {
|
||||
const tag = view[off];
|
||||
if (tag === undefined)
|
||||
throw new Error("DER: truncated");
|
||||
off += 1;
|
||||
const lenByte = view[off];
|
||||
if (lenByte === undefined)
|
||||
throw new Error("DER: truncated");
|
||||
off += 1;
|
||||
let len = lenByte;
|
||||
if (lenByte & 0x80) {
|
||||
const n = lenByte & 0x7f;
|
||||
if (n === 0)
|
||||
throw new Error("DER: indefinite length not supported");
|
||||
if (off + n > view.length)
|
||||
throw new Error("DER: truncated length");
|
||||
len = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = view[off];
|
||||
if (b === undefined)
|
||||
throw new Error("DER: truncated length");
|
||||
len = (len << 8) | b;
|
||||
off += 1;
|
||||
}
|
||||
}
|
||||
const start = off;
|
||||
const end = off + len;
|
||||
if (end > view.length)
|
||||
throw new Error("DER: content truncated");
|
||||
return { tag, len, start, end };
|
||||
};
|
||||
/**
|
||||
* Validates that a DER OID matches X448.
|
||||
* @param view DER-encoded bytes.
|
||||
* @param start Offset of the OID TLV.
|
||||
* @returns True when the OID is X448.
|
||||
*/
|
||||
const ensureOidX448 = (view, start) => {
|
||||
const oid = readTLV(view, start);
|
||||
if (oid.tag !== 0x06)
|
||||
return false;
|
||||
const len = oid.end - oid.start;
|
||||
if (len !== 3)
|
||||
return false;
|
||||
return (view[oid.start] === 0x2b &&
|
||||
view[oid.start + 1] === 0x65 &&
|
||||
view[oid.start + 2] === 0x6f);
|
||||
};
|
||||
/**
|
||||
* Extracts raw 56-byte X448 public key material from SPKI bytes.
|
||||
* @param spkiBytes DER-encoded SPKI bytes.
|
||||
* @returns Raw X448 public key bytes.
|
||||
*/
|
||||
const extractRawX448FromSPKI = (spkiBytes) => {
|
||||
const view = spkiBytes;
|
||||
const outer = readTLV(view, 0);
|
||||
if (outer.tag !== 0x30)
|
||||
throw new Error("SPKI: expected SEQUENCE");
|
||||
const alg = readTLV(view, outer.start);
|
||||
if (alg.tag !== 0x30)
|
||||
throw new Error("SPKI: expected AlgorithmIdentifier");
|
||||
if (!ensureOidX448(view, alg.start))
|
||||
throw new Error("SPKI: not X448");
|
||||
const bitstr = readTLV(view, alg.end);
|
||||
if (bitstr.tag !== 0x03)
|
||||
throw new Error("SPKI: expected BIT STRING");
|
||||
const unusedBits = view[bitstr.start];
|
||||
if (unusedBits !== 0x00)
|
||||
throw new Error("SPKI: unexpected unused bits");
|
||||
const raw = view.subarray(bitstr.start + 1, bitstr.end);
|
||||
if (raw.length !== 56)
|
||||
throw new Error("SPKI: X448 public key must be 56 bytes");
|
||||
return raw;
|
||||
};
|
||||
/**
|
||||
* Extracts raw 56-byte X448 private key material from PKCS#8 bytes.
|
||||
* @param pkcs8Bytes DER-encoded PKCS#8 bytes.
|
||||
* @returns Raw X448 private key bytes.
|
||||
*/
|
||||
const extractRawX448FromPKCS8 = (pkcs8Bytes) => {
|
||||
const view = pkcs8Bytes;
|
||||
const outer = readTLV(view, 0);
|
||||
if (outer.tag !== 0x30)
|
||||
throw new Error("PKCS8: expected SEQUENCE");
|
||||
let off = outer.start;
|
||||
const version = readTLV(view, off);
|
||||
if (version.tag !== 0x02)
|
||||
throw new Error("PKCS8: expected version INTEGER");
|
||||
off = version.end;
|
||||
const alg = readTLV(view, off);
|
||||
if (alg.tag !== 0x30)
|
||||
throw new Error("PKCS8: expected AlgorithmIdentifier");
|
||||
if (!ensureOidX448(view, alg.start))
|
||||
throw new Error("PKCS8: not X448");
|
||||
off = alg.end;
|
||||
const priv = readTLV(view, off);
|
||||
if (priv.tag !== 0x04)
|
||||
throw new Error("PKCS8: expected privateKey OCTET STRING");
|
||||
let raw = view.subarray(priv.start, priv.end);
|
||||
// Some encoders nest another OCTET STRING inside
|
||||
if (raw[0] === 0x04) {
|
||||
const inner = readTLV(raw, 0);
|
||||
if (inner.tag === 0x04) {
|
||||
raw = raw.subarray(inner.start, inner.end);
|
||||
}
|
||||
}
|
||||
if (raw.length !== 56)
|
||||
throw new Error("PKCS8: X448 private key must be 56 bytes");
|
||||
return raw;
|
||||
};
|
||||
/**
|
||||
* Normalizes X448 JWK fields into raw base64url key material.
|
||||
* @param jwk Candidate JWK.
|
||||
* @param label Error label for diagnostics.
|
||||
* @returns Normalized JWK suitable for WebCrypto import.
|
||||
*/
|
||||
const normalizeOkpX448Jwk = (jwk, label) => {
|
||||
if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") {
|
||||
throw new Error(`${label}: expected OKP JWK with crv "X448"`);
|
||||
}
|
||||
const out = { ...jwk };
|
||||
if (out.x) {
|
||||
const xBytes = decodeBase64Auto(out.x);
|
||||
let rawX;
|
||||
try {
|
||||
rawX = extractRawX448FromSPKI(xBytes);
|
||||
}
|
||||
catch {
|
||||
if (xBytes.length !== 56) {
|
||||
throw new Error(`${label}: "x" is not a valid X448 SPKI or raw 56-byte key`);
|
||||
}
|
||||
rawX = xBytes;
|
||||
}
|
||||
out.x = bytesToB64u(rawX);
|
||||
}
|
||||
if (out.d) {
|
||||
const dBytes = decodeBase64Auto(out.d);
|
||||
let rawD;
|
||||
try {
|
||||
rawD = extractRawX448FromPKCS8(dBytes);
|
||||
}
|
||||
catch {
|
||||
if (dBytes.length !== 56) {
|
||||
throw new Error(`${label}: "d" is not a valid X448 PKCS#8 or raw 56-byte key`);
|
||||
}
|
||||
rawD = dBytes;
|
||||
}
|
||||
out.d = bytesToB64u(rawD);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* Returns WebCrypto subtle API when available.
|
||||
* @returns SubtleCrypto instance or undefined.
|
||||
*/
|
||||
const getSubtle = () => globalThis.crypto?.subtle;
|
||||
{
|
||||
/*
|
||||
const hkdfAesGcmFromShared = async (
|
||||
sharedSecret: BufferSource,
|
||||
infoStr: string
|
||||
): Promise<CryptoKey> => {
|
||||
const subtle = getSubtle();
|
||||
if (!subtle) throw new Error("WebCrypto subtle not available");
|
||||
const info = textEncoder.encode(infoStr);
|
||||
const baseKey = await subtle.importKey(
|
||||
"raw",
|
||||
sharedSecret,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return await subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array(0),
|
||||
info,
|
||||
},
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
};
|
||||
*/
|
||||
}
|
||||
const myJwk = normalizeOkpX448Jwk(ownJwk, "own_jwk");
|
||||
const peerJwk = normalizeOkpX448Jwk(otherJwk, "other_jwk");
|
||||
const subtle = getSubtle();
|
||||
//const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`;
|
||||
if (subtle) {
|
||||
const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }];
|
||||
for (const algorithm of algorithms) {
|
||||
try {
|
||||
const [myPriv, peerPub] = await Promise.all([
|
||||
subtle.importKey("jwk", myJwk, algorithm, false, ["deriveBits"]),
|
||||
subtle.importKey("jwk", peerJwk, algorithm, false, []),
|
||||
]);
|
||||
const sharedBits = await subtle.deriveBits({ name: algorithm.name, public: peerPub }, myPriv, 448);
|
||||
const sharedSecret = new Uint8Array(sharedBits);
|
||||
//const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr);
|
||||
return bytesToHex(sharedSecret);
|
||||
}
|
||||
catch {
|
||||
// Browser doesn't support this algorithm, try next or fall through to software fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
const { d: dMyB64u } = myJwk;
|
||||
//const { x: xMyB64u, d: dMyB64u } = myJwk;
|
||||
const { x: xPeerB64u } = peerJwk;
|
||||
if (!dMyB64u || !xPeerB64u) {
|
||||
return "Failed to get shared secret due to missing keys";
|
||||
}
|
||||
const [dRaw, xRawPeer] = [b64uToBytes(dMyB64u), b64uToBytes(xPeerB64u)];
|
||||
if (dRaw.length !== 56 || xRawPeer.length !== 56) {
|
||||
return "Failed to get shared secret due to invalid key lengths";
|
||||
}
|
||||
const { x448 } = await import("@noble/curves/ed448.js");
|
||||
const sharedSecret = new Uint8Array(x448.getSharedSecret(dRaw, xRawPeer));
|
||||
//const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr);
|
||||
return bytesToHex(sharedSecret);
|
||||
}
|
||||
87
dist/index.d.ts
vendored
87
dist/index.d.ts
vendored
|
|
@ -1,87 +0,0 @@
|
|||
import { type TransportClient, READY_STATE } from "@tensamin/ttp-core";
|
||||
import { createSchema } from "./schema";
|
||||
import type { z, ZodObject } from "zod";
|
||||
/**
|
||||
* @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 declare class TAuthClient {
|
||||
frontendUrl: string;
|
||||
identifier: string;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
|
||||
redirectUrl: URL;
|
||||
schema: ReturnType<typeof createSchema>;
|
||||
httpServer: {
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
clientMap: Map<string, TransportClient<{
|
||||
identification: {
|
||||
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: 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>;
|
||||
};
|
||||
}>>;
|
||||
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> | void;
|
||||
redirectUrl: string;
|
||||
appData: ZodObject;
|
||||
httpServer?: {
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
htmlSuccessPage?: string;
|
||||
});
|
||||
generateChallenge(userId: number): Promise<string>;
|
||||
solveChallenge(userId: number, challenge: string): Promise<string>;
|
||||
createTTP(userId: number, sessionId: number, omikronUrl: string): Promise<TransportClient<typeof this.schema>>;
|
||||
loadData(userId: number, sessionId: number): Promise<Record<string, unknown>>;
|
||||
saveData(userId: number, sessionId: number, data: any): Promise<void>;
|
||||
private createTemporaryTTPConnection;
|
||||
generateLink(challenge?: string): string;
|
||||
}
|
||||
export declare function getFriendlyReadyState(stateIndex: number): keyof typeof READY_STATE;
|
||||
export declare function generateKeyPair(): {
|
||||
private: string;
|
||||
public: string;
|
||||
};
|
||||
export * from "./crypto";
|
||||
export * from "./schema";
|
||||
export * from "./user";
|
||||
459
dist/index.html
vendored
459
dist/index.html
vendored
|
|
@ -1,459 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content="Astro v6.1.6" />
|
||||
<title>Login successful</title>
|
||||
<script>
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const applyTheme = (isDark) => {
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
};
|
||||
|
||||
applyTheme(media.matches);
|
||||
media.addEventListener("change", (event) => applyTheme(event.matches));
|
||||
</script>
|
||||
<style>
|
||||
@layer properties {
|
||||
@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or
|
||||
((-moz-orient: inline) and (not (color: rgb(from red r g b)))) {
|
||||
*,
|
||||
:before,
|
||||
:after,
|
||||
::backdrop {
|
||||
--tw-font-weight: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer theme {
|
||||
:root,
|
||||
:host {
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
--spacing: 0.25rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-4xl: 2.25rem;
|
||||
--text-4xl--line-height: calc(2.5 / 2.25);
|
||||
--font-weight-semibold: 600;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*,
|
||||
:after,
|
||||
:before,
|
||||
::backdrop {
|
||||
box-sizing: border-box;
|
||||
border: 0 solid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
border: 0 solid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
:host {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
line-height: 1.5;
|
||||
font-family: var(
|
||||
--default-font-family,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji"
|
||||
);
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(
|
||||
--default-font-variation-settings,
|
||||
normal
|
||||
);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: var(
|
||||
--default-mono-font-family,
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
"Liberation Mono",
|
||||
"Courier New",
|
||||
monospace
|
||||
);
|
||||
font-feature-settings: var(
|
||||
--default-mono-font-feature-settings,
|
||||
normal
|
||||
);
|
||||
font-variation-settings: var(
|
||||
--default-mono-font-variation-settings,
|
||||
normal
|
||||
);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub,
|
||||
sup {
|
||||
vertical-align: baseline;
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol,
|
||||
ul,
|
||||
menu {
|
||||
list-style: none;
|
||||
}
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
vertical-align: middle;
|
||||
display: block;
|
||||
}
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
background-color: #0000;
|
||||
border-radius: 0;
|
||||
}
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
background-color: #0000;
|
||||
border-radius: 0;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not ((-webkit-appearance: -apple-pay-button))) or
|
||||
(contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentColor;
|
||||
}
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
::placeholder {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-datetime-edit-month-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-datetime-edit-day-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-datetime-edit-hour-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-datetime-edit-minute-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-datetime-edit-second-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-datetime-edit-millisecond-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-calendar-picker-indicator {
|
||||
line-height: 1;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button,
|
||||
input:where([type="button"], [type="reset"], [type="submit"]) {
|
||||
appearance: button;
|
||||
}
|
||||
::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden="until-found"])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer components;
|
||||
@layer utilities {
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.size-80 {
|
||||
width: calc(var(--spacing) * 80);
|
||||
height: calc(var(--spacing) * 80);
|
||||
}
|
||||
.h-screen {
|
||||
height: 100vh;
|
||||
}
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.w-screen {
|
||||
width: 100vw;
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.gap-12 {
|
||||
gap: calc(var(--spacing) * 12);
|
||||
}
|
||||
.gap-30 {
|
||||
gap: calc(var(--spacing) * 30);
|
||||
}
|
||||
.bg-background {
|
||||
background-color: var(--background);
|
||||
}
|
||||
.text-4xl {
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||
}
|
||||
.text-xl {
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--tw-leading, var(--text-xl--line-height));
|
||||
}
|
||||
.font-semibold {
|
||||
--tw-font-weight: var(--font-weight-semibold);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.text-foreground {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.text-muted-foreground {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.text-primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
:root {
|
||||
--background: oklch(100% 0 0);
|
||||
--foreground: oklch(14.5% 0 0);
|
||||
--card: oklch(100% 0 0);
|
||||
--card-foreground: oklch(14.5% 0 0);
|
||||
--popover: oklch(100% 0 0);
|
||||
--popover-foreground: oklch(14.5% 0 0);
|
||||
--primary: oklch(51.1% 0.096 186.391);
|
||||
--primary-foreground: oklch(98.4% 0.014 180.72);
|
||||
--secondary: oklch(96.7% 0.001 286.375);
|
||||
--secondary-foreground: oklch(21% 0.006 285.885);
|
||||
--muted: oklch(97% 0 0);
|
||||
--muted-foreground: oklch(55.6% 0 0);
|
||||
--accent: oklch(97% 0 0);
|
||||
--accent-foreground: oklch(20.5% 0 0);
|
||||
--destructive: oklch(57.7% 0.245 27.325);
|
||||
--border: oklch(92.2% 0 0);
|
||||
--input: oklch(92.2% 0 0);
|
||||
--ring: oklch(70.8% 0 0);
|
||||
--chart-1: oklch(85.5% 0.138 181.071);
|
||||
--chart-2: oklch(70.4% 0.14 182.503);
|
||||
--chart-3: oklch(60% 0.118 184.704);
|
||||
--chart-4: oklch(51.1% 0.096 186.391);
|
||||
--chart-5: oklch(43.7% 0.078 188.216);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(98.5% 0 0);
|
||||
--sidebar-foreground: oklch(14.5% 0 0);
|
||||
--sidebar-primary: oklch(60% 0.118 184.704);
|
||||
--sidebar-primary-foreground: oklch(98.4% 0.014 180.72);
|
||||
--sidebar-accent: oklch(97% 0 0);
|
||||
--sidebar-accent-foreground: oklch(20.5% 0 0);
|
||||
--sidebar-border: oklch(92.2% 0 0);
|
||||
--sidebar-ring: oklch(70.8% 0 0);
|
||||
}
|
||||
.dark {
|
||||
--background: oklch(14.5% 0 0);
|
||||
--foreground: oklch(98.5% 0 0);
|
||||
--card: oklch(22.5% 0 0);
|
||||
--card-foreground: oklch(98.5% 0 0);
|
||||
--popover: oklch(20.5% 0 0);
|
||||
--popover-foreground: oklch(98.5% 0 0);
|
||||
--primary: oklch(43.7% 0.078 188.216);
|
||||
--primary-foreground: oklch(98.4% 0.014 180.72);
|
||||
--secondary: oklch(27.4% 0.006 286.033);
|
||||
--secondary-foreground: oklch(98.5% 0 0);
|
||||
--muted: oklch(26.9% 0 0);
|
||||
--muted-foreground: oklch(70.8% 0 0);
|
||||
--accent: oklch(26.9% 0 0);
|
||||
--accent-foreground: oklch(98.5% 0 0);
|
||||
--destructive: oklch(70.4% 0.191 22.216);
|
||||
--border: oklch(100% 0 0/0.1);
|
||||
--input: oklch(100% 0 0/0.15);
|
||||
--ring: oklch(55.6% 0 0);
|
||||
--chart-1: oklch(85.5% 0.138 181.071);
|
||||
--chart-2: oklch(70.4% 0.14 182.503);
|
||||
--chart-3: oklch(60% 0.118 184.704);
|
||||
--chart-4: oklch(51.1% 0.096 186.391);
|
||||
--chart-5: oklch(43.7% 0.078 188.216);
|
||||
--sidebar: oklch(19% 0 0);
|
||||
--sidebar-foreground: oklch(98.5% 0 0);
|
||||
--sidebar-primary: oklch(70.4% 0.14 182.503);
|
||||
--sidebar-primary-foreground: oklch(27.7% 0.046 192.524);
|
||||
--sidebar-accent: oklch(26.9% 0 0);
|
||||
--sidebar-accent-foreground: oklch(98.5% 0 0);
|
||||
--sidebar-border: oklch(100% 0 0/0.1);
|
||||
--sidebar-ring: oklch(55.6% 0 0);
|
||||
}
|
||||
@property --tw-font-weight {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
class="min-h-screen bg-background text-foreground w-screen h-screen flex items-center justify-center flex-col gap-30"
|
||||
>
|
||||
<div class="flex flex-col gap-12 items-center justify-center">
|
||||
<h1 class="text-4xl font-semibold">Login successful</h1>
|
||||
<p class="text-xl text-muted-foreground">You can close this page now.</p>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
class="lucide lucide-check size-80 text-primary"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5"></path>
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
219
dist/index.js
vendored
219
dist/index.js
vendored
|
|
@ -1,219 +0,0 @@
|
|||
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(new URL("./index.html", import.meta.url), "utf8")), {
|
||||
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}`);
|
||||
const client = 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.set(`${userId}:${sessionId}`, client);
|
||||
client.connect();
|
||||
return Promise.resolve(client);
|
||||
}
|
||||
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";
|
||||
33
dist/schema.d.ts
vendored
33
dist/schema.d.ts
vendored
|
|
@ -1,33 +0,0 @@
|
|||
import z, { ZodObject } from "zod";
|
||||
export declare function createSchema(appData: ZodObject): {
|
||||
identification: {
|
||||
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: 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>;
|
||||
};
|
||||
};
|
||||
35
dist/schema.js
vendored
35
dist/schema.js
vendored
|
|
@ -1,35 +0,0 @@
|
|||
import z, { ZodObject } from "zod";
|
||||
export function createSchema(appData) {
|
||||
return {
|
||||
identification: {
|
||||
request: z.object({
|
||||
app_identifier: z.string(),
|
||||
app_session_id: z.number(),
|
||||
app_public_key: z.string(),
|
||||
user_id: z.number(),
|
||||
}),
|
||||
response: z.object({
|
||||
challenge: z.base64(),
|
||||
public_key: z.base64(),
|
||||
}),
|
||||
},
|
||||
challenge_response: {
|
||||
request: z.object({
|
||||
challenge: z.base64(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
load_app_data: {
|
||||
request: z.object({}),
|
||||
response: z.object({
|
||||
app_data: z.string(),
|
||||
}),
|
||||
},
|
||||
save_app_data: {
|
||||
request: z.object({
|
||||
app_data: z.string(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
};
|
||||
}
|
||||
13
dist/user.d.ts
vendored
13
dist/user.d.ts
vendored
|
|
@ -1,13 +0,0 @@
|
|||
export type User = {
|
||||
status: string;
|
||||
username: string;
|
||||
public_key: string;
|
||||
user_id: number;
|
||||
iota_id: number;
|
||||
sub_level: number;
|
||||
sub_end: number;
|
||||
display: string;
|
||||
status_message: string;
|
||||
about: string;
|
||||
};
|
||||
export declare function get(userId: number): Promise<User | undefined>;
|
||||
18
dist/user.js
vendored
18
dist/user.js
vendored
|
|
@ -1,18 +0,0 @@
|
|||
const userCacheMap = new Map();
|
||||
export async function get(userId) {
|
||||
try {
|
||||
const cachedUser = userCacheMap.get(userId);
|
||||
if (cachedUser) {
|
||||
return cachedUser;
|
||||
}
|
||||
const fetchedUser = await fetch("https://omega.tensamin.net/api/get/user/" + userId).then((res) => res.json());
|
||||
if (fetchedUser) {
|
||||
userCacheMap.set(userId, fetchedUser);
|
||||
}
|
||||
return fetchedUser;
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error fetching user data for userId:", userId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
34
package.json
34
package.json
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"name": "@tensamin/tauth-sdk",
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run test && tsc -p tsconfig.build.json && cp src/index.html dist/index.html",
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/node": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.9.tar.gz",
|
||||
"@webtransport-bun/webtransport": "^0.3.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
439
src/crypto.ts
439
src/crypto.ts
|
|
@ -1,439 +0,0 @@
|
|||
type Base64URLString = string;
|
||||
|
||||
type JWK = {
|
||||
kty: string;
|
||||
crv: string;
|
||||
x?: string;
|
||||
d?: string;
|
||||
};
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const crypto = globalThis.crypto;
|
||||
|
||||
export async function encrypt(
|
||||
secret: string,
|
||||
plaintext: string,
|
||||
): Promise<string> {
|
||||
const sharedSecret = new Uint8Array(
|
||||
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
|
||||
);
|
||||
|
||||
const hkdfKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedSecret,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
|
||||
const okm = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array([]),
|
||||
info: textEncoder.encode("x448-aes-gcm-no-overhead"),
|
||||
},
|
||||
hkdfKey,
|
||||
44 * 8,
|
||||
);
|
||||
|
||||
const okmBytes = new Uint8Array(okm);
|
||||
const keyBytes = okmBytes.slice(0, 32);
|
||||
const nonce = okmBytes.slice(32, 44);
|
||||
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBytes,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["encrypt"],
|
||||
);
|
||||
|
||||
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
aesKey,
|
||||
textEncoder.encode(plaintext),
|
||||
);
|
||||
|
||||
return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts base64 ciphertext with a symmetric key derived from a hex shared secret.
|
||||
* @param secret Hex-encoded shared secret.
|
||||
* @param ciphertext Base64 ciphertext to decrypt.
|
||||
* @returns Decrypted UTF-8 plaintext.
|
||||
*/
|
||||
export async function decrypt(
|
||||
secret: string,
|
||||
ciphertext: Base64URLString | string,
|
||||
): Promise<string> {
|
||||
const sharedSecret = new Uint8Array(
|
||||
secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
|
||||
);
|
||||
|
||||
const ciphertextBytes = Uint8Array.from(atob(ciphertext), (c) =>
|
||||
c.charCodeAt(0),
|
||||
);
|
||||
|
||||
const hkdfKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedSecret,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
|
||||
const okm = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array([]),
|
||||
info: textEncoder.encode("x448-aes-gcm-no-overhead"),
|
||||
},
|
||||
hkdfKey,
|
||||
44 * 8,
|
||||
);
|
||||
|
||||
const okmBytes = new Uint8Array(okm);
|
||||
const keyBytes = okmBytes.slice(0, 32);
|
||||
const nonce = okmBytes.slice(32, 44);
|
||||
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBytes,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["decrypt"],
|
||||
);
|
||||
|
||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: nonce,
|
||||
},
|
||||
aesKey,
|
||||
ciphertextBytes,
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decryptedBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes an X448 shared secret from local and peer key material.
|
||||
* @param ownPrivateKey Local private key in raw/base64/base64url or PKCS#8-wrapped form.
|
||||
* @param ownPublicKey Local public key in raw/base64/base64url or SPKI-wrapped form.
|
||||
* @param otherPublicKey Peer public key in raw/base64/base64url or SPKI-wrapped form.
|
||||
* @returns Hex-encoded shared secret, or a failure message when key material is missing/invalid.
|
||||
*/
|
||||
export async function getSharedSecret(
|
||||
ownPrivateKey: string,
|
||||
ownPublicKey: string,
|
||||
otherPublicKey: string,
|
||||
): Promise<string> {
|
||||
const otherJwk: JWK = { kty: "OKP", crv: "X448", x: otherPublicKey };
|
||||
const ownJwk: JWK = {
|
||||
kty: "OKP",
|
||||
crv: "X448",
|
||||
x: ownPublicKey,
|
||||
d: ownPrivateKey,
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts bytes to a lowercase hex string.
|
||||
* @param u8 Byte array.
|
||||
* @returns Hex string.
|
||||
*/
|
||||
const bytesToHex = (u8: Uint8Array): string =>
|
||||
Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
/**
|
||||
* Decodes standard base64 text into bytes.
|
||||
* @param s Base64 string.
|
||||
* @returns Decoded bytes.
|
||||
*/
|
||||
const b64ToBytes = (s: Base64URLString): Uint8Array => {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes URL-safe base64 text into bytes.
|
||||
* @param s Base64url string.
|
||||
* @returns Decoded bytes.
|
||||
*/
|
||||
const b64uToBytes = (s: Base64URLString): Uint8Array => {
|
||||
const b64 =
|
||||
s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
|
||||
return b64ToBytes(b64);
|
||||
};
|
||||
|
||||
/**
|
||||
* Encodes bytes as URL-safe base64 without padding.
|
||||
* @param u8 Byte array.
|
||||
* @returns Base64url string.
|
||||
*/
|
||||
const bytesToB64u = (u8: Uint8Array): string => {
|
||||
const b64 = btoa(String.fromCharCode(...u8));
|
||||
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes either base64 or base64url text into bytes.
|
||||
* @param s Base64/base64url string.
|
||||
* @returns Decoded bytes.
|
||||
*/
|
||||
const decodeBase64Auto = (s: string): Uint8Array =>
|
||||
/[-_]/.test(s) ? b64uToBytes(s) : b64ToBytes(s);
|
||||
|
||||
/**
|
||||
* Reads a DER TLV item from the provided offset.
|
||||
* @param view DER-encoded bytes.
|
||||
* @param off Start offset.
|
||||
* @returns Parsed TLV metadata with tag, length, and boundaries.
|
||||
*/
|
||||
const readTLV = (view: Uint8Array, off: number) => {
|
||||
const tag = view[off];
|
||||
if (tag === undefined) throw new Error("DER: truncated");
|
||||
off += 1;
|
||||
|
||||
const lenByte = view[off];
|
||||
if (lenByte === undefined) throw new Error("DER: truncated");
|
||||
off += 1;
|
||||
|
||||
let len = lenByte;
|
||||
if (lenByte & 0x80) {
|
||||
const n = lenByte & 0x7f;
|
||||
if (n === 0) throw new Error("DER: indefinite length not supported");
|
||||
if (off + n > view.length) throw new Error("DER: truncated length");
|
||||
len = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = view[off];
|
||||
if (b === undefined) throw new Error("DER: truncated length");
|
||||
len = (len << 8) | b;
|
||||
off += 1;
|
||||
}
|
||||
}
|
||||
const start = off;
|
||||
const end = off + len;
|
||||
if (end > view.length) throw new Error("DER: content truncated");
|
||||
return { tag, len, start, end };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a DER OID matches X448.
|
||||
* @param view DER-encoded bytes.
|
||||
* @param start Offset of the OID TLV.
|
||||
* @returns True when the OID is X448.
|
||||
*/
|
||||
const ensureOidX448 = (view: Uint8Array, start: number): boolean => {
|
||||
const oid = readTLV(view, start);
|
||||
if (oid.tag !== 0x06) return false;
|
||||
const len = oid.end - oid.start;
|
||||
if (len !== 3) return false;
|
||||
return (
|
||||
view[oid.start] === 0x2b &&
|
||||
view[oid.start + 1] === 0x65 &&
|
||||
view[oid.start + 2] === 0x6f
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts raw 56-byte X448 public key material from SPKI bytes.
|
||||
* @param spkiBytes DER-encoded SPKI bytes.
|
||||
* @returns Raw X448 public key bytes.
|
||||
*/
|
||||
const extractRawX448FromSPKI = (spkiBytes: Uint8Array): Uint8Array => {
|
||||
const view = spkiBytes;
|
||||
const outer = readTLV(view, 0);
|
||||
if (outer.tag !== 0x30) throw new Error("SPKI: expected SEQUENCE");
|
||||
const alg = readTLV(view, outer.start);
|
||||
if (alg.tag !== 0x30) throw new Error("SPKI: expected AlgorithmIdentifier");
|
||||
if (!ensureOidX448(view, alg.start)) throw new Error("SPKI: not X448");
|
||||
const bitstr = readTLV(view, alg.end);
|
||||
if (bitstr.tag !== 0x03) throw new Error("SPKI: expected BIT STRING");
|
||||
const unusedBits = view[bitstr.start];
|
||||
if (unusedBits !== 0x00) throw new Error("SPKI: unexpected unused bits");
|
||||
const raw = view.subarray(bitstr.start + 1, bitstr.end);
|
||||
if (raw.length !== 56)
|
||||
throw new Error("SPKI: X448 public key must be 56 bytes");
|
||||
return raw;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts raw 56-byte X448 private key material from PKCS#8 bytes.
|
||||
* @param pkcs8Bytes DER-encoded PKCS#8 bytes.
|
||||
* @returns Raw X448 private key bytes.
|
||||
*/
|
||||
const extractRawX448FromPKCS8 = (pkcs8Bytes: Uint8Array): Uint8Array => {
|
||||
const view = pkcs8Bytes;
|
||||
const outer = readTLV(view, 0);
|
||||
if (outer.tag !== 0x30) throw new Error("PKCS8: expected SEQUENCE");
|
||||
let off = outer.start;
|
||||
|
||||
const version = readTLV(view, off);
|
||||
if (version.tag !== 0x02)
|
||||
throw new Error("PKCS8: expected version INTEGER");
|
||||
off = version.end;
|
||||
|
||||
const alg = readTLV(view, off);
|
||||
if (alg.tag !== 0x30)
|
||||
throw new Error("PKCS8: expected AlgorithmIdentifier");
|
||||
if (!ensureOidX448(view, alg.start)) throw new Error("PKCS8: not X448");
|
||||
off = alg.end;
|
||||
|
||||
const priv = readTLV(view, off);
|
||||
if (priv.tag !== 0x04)
|
||||
throw new Error("PKCS8: expected privateKey OCTET STRING");
|
||||
let raw = view.subarray(priv.start, priv.end);
|
||||
|
||||
// Some encoders nest another OCTET STRING inside
|
||||
if (raw[0] === 0x04) {
|
||||
const inner = readTLV(raw, 0);
|
||||
if (inner.tag === 0x04) {
|
||||
raw = raw.subarray(inner.start, inner.end);
|
||||
}
|
||||
}
|
||||
if (raw.length !== 56)
|
||||
throw new Error("PKCS8: X448 private key must be 56 bytes");
|
||||
return raw;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes X448 JWK fields into raw base64url key material.
|
||||
* @param jwk Candidate JWK.
|
||||
* @param label Error label for diagnostics.
|
||||
* @returns Normalized JWK suitable for WebCrypto import.
|
||||
*/
|
||||
const normalizeOkpX448Jwk = (jwk: JWK, label: string): JWK => {
|
||||
if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "X448") {
|
||||
throw new Error(`${label}: expected OKP JWK with crv "X448"`);
|
||||
}
|
||||
const out = { ...jwk };
|
||||
|
||||
if (out.x) {
|
||||
const xBytes = decodeBase64Auto(out.x);
|
||||
let rawX: Uint8Array;
|
||||
try {
|
||||
rawX = extractRawX448FromSPKI(xBytes);
|
||||
} catch {
|
||||
if (xBytes.length !== 56) {
|
||||
throw new Error(
|
||||
`${label}: "x" is not a valid X448 SPKI or raw 56-byte key`,
|
||||
);
|
||||
}
|
||||
rawX = xBytes;
|
||||
}
|
||||
out.x = bytesToB64u(rawX);
|
||||
}
|
||||
|
||||
if (out.d) {
|
||||
const dBytes = decodeBase64Auto(out.d);
|
||||
let rawD: Uint8Array;
|
||||
try {
|
||||
rawD = extractRawX448FromPKCS8(dBytes);
|
||||
} catch {
|
||||
if (dBytes.length !== 56) {
|
||||
throw new Error(
|
||||
`${label}: "d" is not a valid X448 PKCS#8 or raw 56-byte key`,
|
||||
);
|
||||
}
|
||||
rawD = dBytes;
|
||||
}
|
||||
out.d = bytesToB64u(rawD);
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns WebCrypto subtle API when available.
|
||||
* @returns SubtleCrypto instance or undefined.
|
||||
*/
|
||||
const getSubtle = () => globalThis.crypto?.subtle;
|
||||
|
||||
{
|
||||
/*
|
||||
const hkdfAesGcmFromShared = async (
|
||||
sharedSecret: BufferSource,
|
||||
infoStr: string
|
||||
): Promise<CryptoKey> => {
|
||||
const subtle = getSubtle();
|
||||
if (!subtle) throw new Error("WebCrypto subtle not available");
|
||||
const info = textEncoder.encode(infoStr);
|
||||
const baseKey = await subtle.importKey(
|
||||
"raw",
|
||||
sharedSecret,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return await subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array(0),
|
||||
info,
|
||||
},
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
};
|
||||
*/
|
||||
}
|
||||
|
||||
const myJwk: JWK = normalizeOkpX448Jwk(ownJwk, "own_jwk");
|
||||
const peerJwk: JWK = normalizeOkpX448Jwk(otherJwk, "other_jwk");
|
||||
|
||||
const subtle = getSubtle();
|
||||
//const infoStr = `ECDH-X448-AES-GCM-v1|my=${myJwk.x}|peer=${peerJwk.x}`;
|
||||
|
||||
if (subtle) {
|
||||
const algorithms = [{ name: "ECDH", namedCurve: "X448" }, { name: "X448" }];
|
||||
|
||||
for (const algorithm of algorithms) {
|
||||
try {
|
||||
const [myPriv, peerPub] = await Promise.all([
|
||||
subtle.importKey("jwk", myJwk, algorithm, false, ["deriveBits"]),
|
||||
subtle.importKey("jwk", peerJwk, algorithm, false, []),
|
||||
]);
|
||||
|
||||
const sharedBits = await subtle.deriveBits(
|
||||
{ name: algorithm.name, public: peerPub },
|
||||
myPriv,
|
||||
448,
|
||||
);
|
||||
|
||||
const sharedSecret = new Uint8Array(sharedBits);
|
||||
//const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr);
|
||||
|
||||
return bytesToHex(sharedSecret);
|
||||
} catch {
|
||||
// Browser doesn't support this algorithm, try next or fall through to software fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { d: dMyB64u } = myJwk;
|
||||
//const { x: xMyB64u, d: dMyB64u } = myJwk;
|
||||
const { x: xPeerB64u } = peerJwk;
|
||||
|
||||
if (!dMyB64u || !xPeerB64u) {
|
||||
return "Failed to get shared secret due to missing keys";
|
||||
}
|
||||
|
||||
const [dRaw, xRawPeer] = [b64uToBytes(dMyB64u), b64uToBytes(xPeerB64u)];
|
||||
if (dRaw.length !== 56 || xRawPeer.length !== 56) {
|
||||
return "Failed to get shared secret due to invalid key lengths";
|
||||
}
|
||||
|
||||
const { x448 } = await import("@noble/curves/ed448.js");
|
||||
const sharedSecret = new Uint8Array(x448.getSharedSecret(dRaw, xRawPeer));
|
||||
//const aeadKey = await hkdfAesGcmFromShared(sharedSecret, infoStr);
|
||||
|
||||
return bytesToHex(sharedSecret);
|
||||
}
|
||||
338
src/index.ts
338
src/index.ts
|
|
@ -1,338 +0,0 @@
|
|||
import {
|
||||
createTransportClient,
|
||||
type TransportClient,
|
||||
READY_STATE,
|
||||
} from "@tensamin/ttp-core";
|
||||
import { createSchema } from "./schema";
|
||||
import type { z, ZodObject } from "zod";
|
||||
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: string;
|
||||
identifier: string;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
saveSession: (userId: number, sessionId: number) => Promise<void> | void;
|
||||
redirectUrl: URL;
|
||||
schema: ReturnType<typeof createSchema>;
|
||||
httpServer: {
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
clientMap = new Map<string, TransportClient<typeof this.schema>>();
|
||||
private appData: ZodObject;
|
||||
|
||||
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> | void;
|
||||
redirectUrl: string;
|
||||
appData: ZodObject;
|
||||
httpServer?: {
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
htmlSuccessPage?: string;
|
||||
}) {
|
||||
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(new URL("./index.html", import.meta.url), "utf8")),
|
||||
{
|
||||
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: number): Promise<string> {
|
||||
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: number, challenge: string) {
|
||||
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: number,
|
||||
sessionId: number,
|
||||
omikronUrl: string,
|
||||
): Promise<TransportClient<typeof this.schema>> {
|
||||
console.log(
|
||||
`[tauth] Creating TTP client for user ${userId} with session ${sessionId} at ${omikronUrl}`,
|
||||
);
|
||||
|
||||
const client = 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.set(`${userId}:${sessionId}`, client);
|
||||
client.connect();
|
||||
|
||||
return Promise.resolve(client);
|
||||
}
|
||||
|
||||
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 {
|
||||
return new URL(
|
||||
`?identifier=${this.identifier}&redirect=${this.redirectUrl}${challenge ? `&challenge=${challenge}` : ""}`,
|
||||
this.frontendUrl,
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
|
||||
export function getFriendlyReadyState(
|
||||
stateIndex: number,
|
||||
): keyof typeof READY_STATE {
|
||||
return Object.keys(READY_STATE).find(
|
||||
(key) => READY_STATE[key as keyof typeof READY_STATE] === stateIndex,
|
||||
) as keyof typeof READY_STATE;
|
||||
}
|
||||
|
||||
export function generateKeyPair(): { private: string; public: string } {
|
||||
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);
|
||||
}
|
||||
|
||||
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";
|
||||
507
src/lib.rs
Normal file
507
src/lib.rs
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use mtp::client::{ClientConfig, MTPClient, MTPConnection};
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use mtp::crypto::{Keyring, PublicKeyBundle};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_FRONTEND_URL: &str = "https://tauth.tensamin.net/login";
|
||||
const OMEGA_API: &str = "https://omega.tensamin.net/api";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TAuthError {
|
||||
#[error("invalid url: {0}")]
|
||||
Url(#[from] url::ParseError),
|
||||
#[error("http request failed: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("invalid key material: {0}")]
|
||||
Key(String),
|
||||
#[error("mtp communication failed: {0}")]
|
||||
Mtp(#[from] mtp::common::CommunicationError),
|
||||
#[error("mtp codec failed: {0}")]
|
||||
Codec(#[from] mtp::common::CodecError),
|
||||
#[error("expected string app data in MTP response")]
|
||||
InvalidAppData,
|
||||
#[error("json serialization failed: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("missing host public key; pass one explicitly or expose it in the omikron response")]
|
||||
MissingHostPublicKey,
|
||||
#[error("missing callback parameter: {0}")]
|
||||
MissingCallbackParam(&'static str),
|
||||
#[error("invalid callback parameter `{name}`: {value}")]
|
||||
InvalidCallbackParam { name: &'static str, value: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TAuthKeyPair {
|
||||
pub private: String,
|
||||
pub public: String,
|
||||
}
|
||||
|
||||
pub type TAuthKeys = TAuthKeyPair;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TAuthConfig {
|
||||
pub identifier: String,
|
||||
pub redirect_url: String,
|
||||
pub private_key: String,
|
||||
pub frontend_url: String,
|
||||
pub auth_timeout: Duration,
|
||||
}
|
||||
|
||||
impl TAuthConfig {
|
||||
pub fn new(
|
||||
identifier: impl Into<String>,
|
||||
redirect_url: impl Into<String>,
|
||||
private_key: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
identifier: identifier.into(),
|
||||
redirect_url: redirect_url.into(),
|
||||
private_key: private_key.into(),
|
||||
frontend_url: DEFAULT_FRONTEND_URL.to_string(),
|
||||
auth_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frontend_url(mut self, frontend_url: impl Into<String>) -> Self {
|
||||
self.frontend_url = frontend_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn auth_timeout(mut self, auth_timeout: Duration) -> Self {
|
||||
self.auth_timeout = auth_timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TAuthClient {
|
||||
frontend_url: Url,
|
||||
identifier: String,
|
||||
redirect_url: Url,
|
||||
keyring: Keyring,
|
||||
public_key_bundle: PublicKeyBundle,
|
||||
auth_timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub status: String,
|
||||
pub username: String,
|
||||
pub public_key: String,
|
||||
pub user_id: u64,
|
||||
pub iota_id: u64,
|
||||
pub sub_level: u64,
|
||||
pub sub_end: u64,
|
||||
pub display: String,
|
||||
pub status_message: String,
|
||||
pub about: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct OmikronResponse {
|
||||
ip_address: String,
|
||||
#[serde(default)]
|
||||
public_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthCallback {
|
||||
pub user_id: u64,
|
||||
pub session_id: u64,
|
||||
pub challenge: Option<String>,
|
||||
pub original_challenge: Option<String>,
|
||||
pub host_public_key: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TAuthSession {
|
||||
user_id: u64,
|
||||
session_id: u64,
|
||||
connection: MTPConnection,
|
||||
}
|
||||
|
||||
impl TAuthSession {
|
||||
pub fn new(user_id: u64, session_id: u64, connection: MTPConnection) -> Self {
|
||||
Self {
|
||||
user_id,
|
||||
session_id,
|
||||
connection,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> u64 {
|
||||
self.user_id
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> u64 {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> &MTPConnection {
|
||||
&self.connection
|
||||
}
|
||||
|
||||
pub async fn load_json(&self) -> Result<Value, TAuthError> {
|
||||
load_json(&self.connection).await
|
||||
}
|
||||
|
||||
pub async fn load<T: DeserializeOwned>(&self) -> Result<T, TAuthError> {
|
||||
Ok(serde_json::from_value(self.load_json().await?)?)
|
||||
}
|
||||
|
||||
pub async fn save_json(&self, data: &Value) -> Result<(), TAuthError> {
|
||||
save_json(&self.connection, data).await
|
||||
}
|
||||
|
||||
pub async fn save<T: Serialize>(&self, data: &T) -> Result<(), TAuthError> {
|
||||
self.save_json(&serde_json::to_value(data)?).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TAuthClient {
|
||||
pub fn new(
|
||||
identifier: impl Into<String>,
|
||||
redirect_url: impl AsRef<str>,
|
||||
private_key: impl AsRef<str>,
|
||||
) -> Result<Self, TAuthError> {
|
||||
Self::from_config(TAuthConfig::new(identifier, redirect_url.as_ref(), private_key.as_ref()))
|
||||
}
|
||||
|
||||
pub fn with_frontend_url(
|
||||
identifier: impl Into<String>,
|
||||
redirect_url: impl AsRef<str>,
|
||||
private_key: impl AsRef<str>,
|
||||
frontend_url: impl AsRef<str>,
|
||||
) -> Result<Self, TAuthError> {
|
||||
Self::from_config(
|
||||
TAuthConfig::new(identifier, redirect_url.as_ref(), private_key.as_ref())
|
||||
.frontend_url(frontend_url.as_ref()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_config(config: TAuthConfig) -> Result<Self, TAuthError> {
|
||||
let keyring = decode_keyring(&config.private_key)?;
|
||||
let public_key_bundle = keyring.public_key_bundle();
|
||||
|
||||
Ok(Self {
|
||||
frontend_url: Url::parse(&config.frontend_url)?,
|
||||
identifier: config.identifier,
|
||||
redirect_url: Url::parse(&config.redirect_url)?,
|
||||
keyring,
|
||||
public_key_bundle,
|
||||
auth_timeout: config.auth_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> &str {
|
||||
&self.identifier
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> String {
|
||||
encode_bytes(&self.public_key_bundle.as_bytes())
|
||||
}
|
||||
|
||||
pub fn redirect_url(&self) -> &Url {
|
||||
&self.redirect_url
|
||||
}
|
||||
|
||||
pub fn frontend_url(&self) -> &Url {
|
||||
&self.frontend_url
|
||||
}
|
||||
|
||||
pub fn key_pair(&self) -> TAuthKeyPair {
|
||||
TAuthKeyPair {
|
||||
private: encode_bytes(&self.keyring.to_bytes()),
|
||||
public: self.public_key(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_link(&self, challenge: Option<&str>) -> String {
|
||||
self.auth_url(challenge)
|
||||
}
|
||||
|
||||
pub fn auth_url(&self, challenge: Option<&str>) -> String {
|
||||
let mut link = self.frontend_url.clone();
|
||||
{
|
||||
let mut pairs = link.query_pairs_mut();
|
||||
pairs.append_pair("identifier", &self.identifier);
|
||||
pairs.append_pair("redirect", self.redirect_url.as_str());
|
||||
pairs.append_pair("public_key", &self.public_key());
|
||||
if let Some(challenge) = challenge {
|
||||
pairs.append_pair("challenge", challenge);
|
||||
}
|
||||
}
|
||||
link.to_string()
|
||||
}
|
||||
|
||||
pub fn parse_callback(&self, callback_url: impl AsRef<str>) -> Result<AuthCallback, TAuthError> {
|
||||
parse_callback(callback_url)
|
||||
}
|
||||
|
||||
pub async fn fetch_user(user_id: u64) -> Result<User, TAuthError> {
|
||||
let url = format!("{OMEGA_API}/get/user/{user_id}");
|
||||
Ok(reqwest::get(url).await?.error_for_status()?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn user(&self, user_id: u64) -> Result<User, TAuthError> {
|
||||
Self::fetch_user(user_id).await
|
||||
}
|
||||
|
||||
pub async fn session_from_callback(
|
||||
&self,
|
||||
callback: &AuthCallback,
|
||||
) -> Result<TAuthSession, TAuthError> {
|
||||
let connection = if let Some(host_public_key) = &callback.host_public_key {
|
||||
self.connect(callback.user_id, callback.session_id, host_public_key)
|
||||
.await?
|
||||
} else {
|
||||
self.connect_with_omikron_key(callback.user_id, callback.session_id)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(TAuthSession::new(
|
||||
callback.user_id,
|
||||
callback.session_id,
|
||||
connection,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn connect_with_omikron_key(
|
||||
&self,
|
||||
user_id: u64,
|
||||
session_id: u64,
|
||||
) -> Result<MTPConnection, TAuthError> {
|
||||
let omikron = fetch_omikron(user_id).await?;
|
||||
let host_public_key = omikron.public_key.ok_or(TAuthError::MissingHostPublicKey)?;
|
||||
self.connect_to_omikron(user_id, session_id, omikron.ip_address, &host_public_key)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
&self,
|
||||
user_id: u64,
|
||||
session_id: u64,
|
||||
host_public_key: impl AsRef<str>,
|
||||
) -> Result<MTPConnection, TAuthError> {
|
||||
let omikron = fetch_omikron(user_id).await?;
|
||||
self.connect_to_omikron(user_id, session_id, omikron.ip_address, host_public_key)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn connect_to_omikron(
|
||||
&self,
|
||||
user_id: u64,
|
||||
session_id: u64,
|
||||
omikron_url: impl Into<String>,
|
||||
host_public_key: impl AsRef<str>,
|
||||
) -> Result<MTPConnection, TAuthError> {
|
||||
let host_public_key = decode_public_key_bundle(host_public_key.as_ref())?;
|
||||
let config = ClientConfig::new(omikron_url.into())
|
||||
.with_client_id(session_id)
|
||||
.with_description(format!("tauth:{}:{user_id}", self.identifier))
|
||||
.with_auth_timeout(self.auth_timeout);
|
||||
Ok(MTPClient::auth_connect(config, &self.keyring, &host_public_key).await?)
|
||||
}
|
||||
|
||||
pub async fn connect_or_register(
|
||||
&self,
|
||||
user_id: u64,
|
||||
session_id: Option<u64>,
|
||||
host_public_key: impl AsRef<str>,
|
||||
) -> Result<MTPConnection, TAuthError> {
|
||||
let omikron = fetch_omikron(user_id).await?;
|
||||
self.connect_or_register_to_omikron(user_id, session_id, omikron.ip_address, host_public_key)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn connect_or_register_to_omikron(
|
||||
&self,
|
||||
user_id: u64,
|
||||
session_id: Option<u64>,
|
||||
omikron_url: impl Into<String>,
|
||||
host_public_key: impl AsRef<str>,
|
||||
) -> Result<MTPConnection, TAuthError> {
|
||||
let host_public_key = decode_public_key_bundle(host_public_key.as_ref())?;
|
||||
let config = ClientConfig::new(omikron_url.into())
|
||||
.with_description(format!("tauth:{}:{user_id}", self.identifier))
|
||||
.with_auth_timeout(self.auth_timeout);
|
||||
Ok(MTPClient::auth_connect_or_register(
|
||||
config,
|
||||
session_id,
|
||||
&self.keyring,
|
||||
&host_public_key,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn connect_session(
|
||||
&self,
|
||||
user_id: u64,
|
||||
session_id: u64,
|
||||
host_public_key: impl AsRef<str>,
|
||||
) -> Result<TAuthSession, TAuthError> {
|
||||
let connection = self.connect(user_id, session_id, host_public_key).await?;
|
||||
Ok(TAuthSession::new(user_id, session_id, connection))
|
||||
}
|
||||
|
||||
pub async fn load_data(connection: &MTPConnection) -> Result<Value, TAuthError> {
|
||||
load_json(connection).await
|
||||
}
|
||||
|
||||
pub async fn save_data(connection: &MTPConnection, data: &Value) -> Result<(), TAuthError> {
|
||||
save_json(connection, data).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_key_pair() -> TAuthKeyPair {
|
||||
let keyring = Keyring::generate();
|
||||
TAuthKeyPair {
|
||||
private: encode_bytes(&keyring.to_bytes()),
|
||||
public: encode_bytes(&keyring.public_key_bundle().as_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_keypair() -> TAuthKeyPair {
|
||||
generate_key_pair()
|
||||
}
|
||||
|
||||
pub fn parse_callback(callback_url: impl AsRef<str>) -> Result<AuthCallback, TAuthError> {
|
||||
let url = Url::parse(callback_url.as_ref())?;
|
||||
let query = url.query_pairs();
|
||||
|
||||
let get = |name: &'static str| -> Option<String> {
|
||||
query
|
||||
.clone()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
};
|
||||
|
||||
let user_id = parse_required_u64("userId", get("userId"))?;
|
||||
let session_id = parse_required_u64("sessionId", get("sessionId"))?;
|
||||
|
||||
Ok(AuthCallback {
|
||||
user_id,
|
||||
session_id,
|
||||
challenge: get("challenge"),
|
||||
original_challenge: get("originalChallenge"),
|
||||
host_public_key: get("hostPublicKey").or_else(|| get("host_public_key")),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn load_json(connection: &MTPConnection) -> Result<Value, TAuthError> {
|
||||
let request = CommunicationValue::new(CommunicationType::LoadAppData);
|
||||
let response = connection
|
||||
.request(&request, Some(CommunicationType::LoadAppDataResponse))
|
||||
.await?;
|
||||
let app_data = match response.get_data(DataType::AppData) {
|
||||
DataValue::Str(value) => value,
|
||||
_ => return Err(TAuthError::InvalidAppData),
|
||||
};
|
||||
Ok(serde_json::from_str(app_data)?)
|
||||
}
|
||||
|
||||
pub async fn save_json(connection: &MTPConnection, data: &Value) -> Result<(), TAuthError> {
|
||||
let app_data = serde_json::to_string(data)?;
|
||||
let request = CommunicationValue::new(CommunicationType::SaveAppData)
|
||||
.add_typed_default(DataType::AppData, DataValue::Str(app_data));
|
||||
connection
|
||||
.request(&request, Some(CommunicationType::SaveAppDataResponse))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_required_u64(name: &'static str, value: Option<String>) -> Result<u64, TAuthError> {
|
||||
let value = value.ok_or(TAuthError::MissingCallbackParam(name))?;
|
||||
value
|
||||
.parse()
|
||||
.map_err(|_| TAuthError::InvalidCallbackParam { name, value })
|
||||
}
|
||||
|
||||
fn encode_bytes(bytes: &[u8]) -> String {
|
||||
STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
fn decode_bytes(value: &str) -> Result<Vec<u8>, TAuthError> {
|
||||
STANDARD
|
||||
.decode(value.trim())
|
||||
.map_err(|error| TAuthError::Key(error.to_string()))
|
||||
}
|
||||
|
||||
fn decode_keyring(value: &str) -> Result<Keyring, TAuthError> {
|
||||
Keyring::from_bytes(&decode_bytes(value)?).map_err(|error| TAuthError::Key(error.to_string()))
|
||||
}
|
||||
|
||||
fn decode_public_key_bundle(value: &str) -> Result<PublicKeyBundle, TAuthError> {
|
||||
PublicKeyBundle::from_bytes(&decode_bytes(value)?)
|
||||
.map_err(|error| TAuthError::Key(error.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_omikron(user_id: u64) -> Result<OmikronResponse, TAuthError> {
|
||||
let url = format!("{OMEGA_API}/get/omikron/{user_id}");
|
||||
Ok(reqwest::get(url).await?.error_for_status()?.json().await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_keys_are_mtp_key_material() {
|
||||
let keys = generate_key_pair();
|
||||
let keyring = decode_keyring(&keys.private).unwrap();
|
||||
let public = decode_public_key_bundle(&keys.public).unwrap();
|
||||
|
||||
assert_eq!(public.as_bytes(), keyring.public_key_bundle().as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_generates_auth_link_with_public_key() {
|
||||
let keys = generate_key_pair();
|
||||
let client = TAuthClient::with_frontend_url(
|
||||
"test.app",
|
||||
"https://app.example/callback",
|
||||
keys.private,
|
||||
"https://tauth.example/login",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let link = Url::parse(&client.generate_link(Some("abc123"))).unwrap();
|
||||
assert_eq!(link.query_pairs().find(|(key, _)| key == "identifier").unwrap().1, "test.app");
|
||||
assert_eq!(link.query_pairs().find(|(key, _)| key == "challenge").unwrap().1, "abc123");
|
||||
assert!(link.query_pairs().any(|(key, value)| key == "public_key" && !value.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_callback_url() {
|
||||
let callback = parse_callback(
|
||||
"https://app.example/callback?userId=42&sessionId=7&challenge=a&originalChallenge=b&hostPublicKey=c",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(callback.user_id, 42);
|
||||
assert_eq!(callback.session_id, 7);
|
||||
assert_eq!(callback.challenge.as_deref(), Some("a"));
|
||||
assert_eq!(callback.original_challenge.as_deref(), Some("b"));
|
||||
assert_eq!(callback.host_public_key.as_deref(), Some("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_snake_case_host_key_callback_url() {
|
||||
let callback = parse_callback(
|
||||
"https://app.example/callback?userId=42&sessionId=7&host_public_key=c",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(callback.host_public_key.as_deref(), Some("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_requires_user_id_and_session_id() {
|
||||
let err = parse_callback("https://app.example/callback?userId=42").unwrap_err();
|
||||
assert!(matches!(err, TAuthError::MissingCallbackParam("sessionId")));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import z, { ZodObject } from "zod";
|
||||
|
||||
export function createSchema(appData: ZodObject) {
|
||||
return {
|
||||
identification: {
|
||||
request: z.object({
|
||||
app_identifier: z.string(),
|
||||
app_session_id: z.number(),
|
||||
app_public_key: z.string(),
|
||||
user_id: z.number(),
|
||||
}),
|
||||
response: z.object({
|
||||
challenge: z.base64(),
|
||||
public_key: z.base64(),
|
||||
}),
|
||||
},
|
||||
challenge_response: {
|
||||
request: z.object({
|
||||
challenge: z.base64(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
|
||||
load_app_data: {
|
||||
request: z.object({}),
|
||||
response: z.object({
|
||||
app_data: z.string(),
|
||||
}),
|
||||
},
|
||||
save_app_data: {
|
||||
request: z.object({
|
||||
app_data: z.string(),
|
||||
}),
|
||||
response: z.object({}),
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/user.ts
34
src/user.ts
|
|
@ -1,34 +0,0 @@
|
|||
export type User = {
|
||||
status: string;
|
||||
username: string;
|
||||
public_key: string;
|
||||
user_id: number;
|
||||
iota_id: number;
|
||||
sub_level: number;
|
||||
sub_end: number;
|
||||
display: string;
|
||||
status_message: string;
|
||||
about: string;
|
||||
};
|
||||
|
||||
const userCacheMap = new Map<number, User>();
|
||||
|
||||
export async function get(userId: number): Promise<User | undefined> {
|
||||
try {
|
||||
const cachedUser = userCacheMap.get(userId);
|
||||
if (cachedUser) {
|
||||
return cachedUser;
|
||||
}
|
||||
|
||||
const fetchedUser = await fetch(
|
||||
"https://omega.tensamin.net/api/get/user/" + userId,
|
||||
).then((res) => res.json());
|
||||
if (fetchedUser) {
|
||||
userCacheMap.set(userId, fetchedUser);
|
||||
}
|
||||
return fetchedUser;
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data for userId:", userId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { TAuthClient, generateKeyPair } from "../src/index";
|
||||
import z from "zod";
|
||||
|
||||
const appDataSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
function createClient(overrides?: Partial<ConstructorParameters<typeof TAuthClient>[0]>) {
|
||||
const keys = generateKeyPair();
|
||||
return new TAuthClient({
|
||||
frontendUrl: "https://tauth.example.com/login",
|
||||
identifier: "test.app",
|
||||
privateKey: keys.private,
|
||||
publicKey: keys.public,
|
||||
saveSession: () => {},
|
||||
redirectUrl: "https://test.app/callback",
|
||||
appData: appDataSchema,
|
||||
httpServer: { port: 0, hostname: "localhost" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("TAuthClient", () => {
|
||||
test("constructor throws if saveSession is not a function", () => {
|
||||
const keys = generateKeyPair();
|
||||
expect(() => {
|
||||
new TAuthClient({
|
||||
frontendUrl: "https://tauth.example.com/login",
|
||||
identifier: "test.app",
|
||||
privateKey: keys.private,
|
||||
publicKey: keys.public,
|
||||
saveSession: "not-a-function" as any,
|
||||
redirectUrl: "https://test.app/callback",
|
||||
appData: appDataSchema,
|
||||
httpServer: { port: 0, hostname: "localhost" },
|
||||
});
|
||||
}).toThrow("saveSession must be provided as a function");
|
||||
});
|
||||
|
||||
test("constructor sets properties correctly", () => {
|
||||
const keys = generateKeyPair();
|
||||
const saveSession = () => {};
|
||||
const client = createClient({
|
||||
frontendUrl: "https://tauth.example.com/login",
|
||||
identifier: "my-app",
|
||||
privateKey: keys.private,
|
||||
publicKey: keys.public,
|
||||
saveSession,
|
||||
redirectUrl: "https://my-app.com/callback",
|
||||
});
|
||||
|
||||
expect(client.frontendUrl).toBe("https://tauth.example.com/login");
|
||||
expect(client.identifier).toBe("my-app");
|
||||
expect(client.privateKey).toBe(keys.private);
|
||||
expect(client.publicKey).toBe(keys.public);
|
||||
expect(client.saveSession).toBe(saveSession);
|
||||
expect(client.redirectUrl.toString()).toBe("https://my-app.com/callback");
|
||||
});
|
||||
|
||||
test("constructor uses default httpServer when not provided", () => {
|
||||
const keys = generateKeyPair();
|
||||
const client = new TAuthClient({
|
||||
frontendUrl: "https://tauth.example.com/login",
|
||||
identifier: "test.app",
|
||||
privateKey: keys.private,
|
||||
publicKey: keys.public,
|
||||
saveSession: () => {},
|
||||
redirectUrl: "https://test.app/callback",
|
||||
appData: appDataSchema,
|
||||
httpServer: { port: 0, hostname: "localhost" },
|
||||
});
|
||||
|
||||
expect(client.httpServer).toBeDefined();
|
||||
expect(client.httpServer.port).toBe(0);
|
||||
});
|
||||
|
||||
test("generateLink produces correct URL without challenge", () => {
|
||||
const client = Object.create(TAuthClient.prototype) as TAuthClient;
|
||||
client.frontendUrl = "https://tauth.example.com/login";
|
||||
client.identifier = "test-app";
|
||||
client.redirectUrl = new URL("https://test.app/callback");
|
||||
|
||||
const link = client.generateLink();
|
||||
const parsed = new URL(link);
|
||||
|
||||
expect(parsed.origin).toBe("https://tauth.example.com");
|
||||
expect(parsed.pathname).toBe("/login");
|
||||
expect(parsed.searchParams.get("identifier")).toBe("test-app");
|
||||
expect(parsed.searchParams.get("redirect")).toBe("https://test.app/callback");
|
||||
expect(parsed.searchParams.has("challenge")).toBe(false);
|
||||
});
|
||||
|
||||
test("generateLink includes challenge when provided", () => {
|
||||
const client = Object.create(TAuthClient.prototype) as TAuthClient;
|
||||
client.frontendUrl = "https://tauth.example.com/login";
|
||||
client.identifier = "test-app";
|
||||
client.redirectUrl = new URL("https://test.app/callback");
|
||||
|
||||
const link = client.generateLink("abc123");
|
||||
const parsed = new URL(link);
|
||||
|
||||
expect(parsed.searchParams.get("challenge")).toBe("abc123");
|
||||
expect(parsed.searchParams.get("identifier")).toBe("test-app");
|
||||
});
|
||||
|
||||
test("schema is created from appData", () => {
|
||||
const client = createClient();
|
||||
|
||||
expect(client.schema).toBeDefined();
|
||||
expect(client.schema.identification).toBeDefined();
|
||||
expect(client.schema.challenge_response).toBeDefined();
|
||||
expect(client.schema.load_app_data).toBeDefined();
|
||||
expect(client.schema.save_app_data).toBeDefined();
|
||||
});
|
||||
|
||||
test("clientMap is initialized empty", () => {
|
||||
const client = createClient();
|
||||
expect(client.clientMap.size).toBe(0);
|
||||
});
|
||||
|
||||
test("generateKeyPair returns valid base64 key pair", () => {
|
||||
const keys = generateKeyPair();
|
||||
|
||||
expect(keys.private).toBeDefined();
|
||||
expect(keys.public).toBeDefined();
|
||||
expect(typeof keys.private).toBe("string");
|
||||
expect(typeof keys.public).toBe("string");
|
||||
|
||||
const privBuf = Buffer.from(keys.private, "base64");
|
||||
const pubBuf = Buffer.from(keys.public, "base64");
|
||||
expect(privBuf.length).toBe(56);
|
||||
expect(pubBuf.length).toBe(56);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": false,
|
||||
"emitDeclarationOnly": false,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "esnext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
// Node.js types
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
11
type-maps.yaml
Normal file
11
type-maps.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
protocol_version: "1.0"
|
||||
|
||||
type_maps:
|
||||
"1.0":
|
||||
CommunicationTypes:
|
||||
LoadAppData: 32
|
||||
LoadAppDataResponse: 33
|
||||
SaveAppData: 34
|
||||
SaveAppDataResponse: 35
|
||||
DataTypes:
|
||||
AppData: 32
|
||||
Loading…
Reference in a new issue