@uns-kit/core
Version:
Core utilities and runtime building blocks for UNS-based realtime transformers.
186 lines • 7.42 kB
JavaScript
import { ConfigFile } from "../../config-file.js";
import { SecureStoreFactory } from "./secure-store.js";
import jwt from "jsonwebtoken";
import readline from "readline";
const cfg = await ConfigFile.loadConfig();
/**
* AuthClient handles acquiring and refreshing JWT access tokens
* using the configured REST base URL.
*/
export class AuthClient {
restBase;
namespace;
store;
constructor(restBase) {
this.restBase = restBase.replace(/\/$/, "");
// namespace store by rest base to allow multiple environments
this.namespace = `uns-auth:${this.restBase}`;
}
static async create() {
const restBase = cfg?.uns?.rest;
if (!restBase)
throw new Error("config.uns.rest is not set");
const client = new AuthClient(restBase);
client.store = await SecureStoreFactory.create(client.namespace);
return client;
}
async getAccessToken() {
const accessToken = await this.store.get("accessToken");
const refreshToken = await this.store.get("refreshToken");
if (accessToken && !AuthClient.isExpired(accessToken)) {
return accessToken;
}
// Try refresh if we have refresh token
if (refreshToken) {
try {
const refreshed = await this.refresh(refreshToken);
await this.persistTokens(refreshed.accessToken, refreshed.refreshToken);
return refreshed.accessToken;
}
catch {
// ignore, fallback to login
}
}
// First try to get email and password from config
const configEmail = cfg?.uns?.email;
const configPassword = cfg?.uns?.password;
if (typeof configEmail === "string" && typeof configPassword === "string") {
try {
const loggedIn = await this.login(configEmail, configPassword);
await this.persistTokens(loggedIn.accessToken, loggedIn.refreshToken);
return loggedIn.accessToken;
}
catch {
// ignore, fallback to interactive login
}
}
// Interactive login
const { email, password } = await AuthClient.promptCredentials();
const loggedIn = await this.login(email, password);
await this.persistTokens(loggedIn.accessToken, loggedIn.refreshToken);
return loggedIn.accessToken;
}
static isExpired(token, skewSeconds = 30) {
try {
const decoded = jwt.decode(token);
if (!decoded || typeof decoded !== "object" || !decoded.exp)
return true;
const now = Math.floor(Date.now() / 1000);
return decoded.exp <= now + skewSeconds;
}
catch {
return true;
}
}
static endpoint(base, tail) {
// base ends without trailing slash; tail must not start with slash
const b = base.replace(/\/$/, "");
const t = tail.replace(/^\//, "");
return `${b}/${t}`;
}
static async fetchWithTimeout(url, init = {}, timeoutMs = 10_000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(url, { ...init, signal: controller.signal });
return resp;
}
finally {
clearTimeout(id);
}
}
static extractRefreshCookie(resp) {
const anyHeaders = resp.headers;
const getSetCookie = typeof anyHeaders.getSetCookie === "function" ? anyHeaders.getSetCookie.bind(anyHeaders) : null;
const candidates = getSetCookie ? getSetCookie() : (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []);
for (const header of candidates) {
const match = header.match(/(?:^|;\s*)RefreshToken=([^;]+)/i);
if (match)
return match[1];
}
return null;
}
async login(email, password) {
const url = AuthClient.endpoint(this.restBase, "auth/login");
const resp = await AuthClient.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}, 12_000);
if (!resp.ok) {
throw new Error(`Login failed: ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json());
const refreshToken = AuthClient.extractRefreshCookie(resp);
if (!data?.accessToken || !refreshToken) {
throw new Error("Login response missing tokens");
}
return { accessToken: data.accessToken, refreshToken };
}
async refresh(refreshToken) {
const url = AuthClient.endpoint(this.restBase, "auth/refresh");
const resp = await AuthClient.fetchWithTimeout(url, {
method: "POST",
headers: {
// server expects cookie RefreshToken
"Cookie": `RefreshToken=${refreshToken}`,
},
}, 8_000);
if (!resp.ok) {
throw new Error(`Refresh failed: ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json());
const newRefreshToken = AuthClient.extractRefreshCookie(resp) || refreshToken;
if (!data?.accessToken)
throw new Error("Refresh response missing accessToken");
return { accessToken: data.accessToken, refreshToken: newRefreshToken };
}
async persistTokens(accessToken, refreshToken) {
await this.store.set("accessToken", accessToken);
await this.store.set("refreshToken", refreshToken);
}
static async promptCredentials() {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const ask = (q) => new Promise((resolve) => rl.question(q, (ans) => resolve(ans.trim())));
const askMasked = (q) => new Promise((resolve) => {
const anyRl = rl;
const out = anyRl.output || process.stdout;
const origWrite = anyRl._writeToOutput?.bind(rl) || ((s) => out.write(s));
anyRl.stdoutMuted = true;
anyRl._writeToOutput = function (stringToWrite) {
// Keep prompt text intact; mask user input
if (anyRl.stdoutMuted) {
if (stringToWrite.startsWith(q)) {
out.write(stringToWrite);
}
else if (stringToWrite.endsWith("\n")) {
out.write("\n");
}
else {
out.write("*");
}
}
else {
origWrite(stringToWrite);
}
};
rl.question(q, (value) => {
anyRl.stdoutMuted = false;
anyRl._writeToOutput = origWrite;
out.write("\n");
resolve(value.trim());
});
});
try {
const email = await ask("Email: ");
const password = await askMasked("Password: ");
rl.close();
return { email, password };
}
catch (e) {
rl.close();
throw e;
}
}
}
//# sourceMappingURL=auth-client.js.map