168 lines
5.2 KiB
TypeScript
168 lines
5.2 KiB
TypeScript
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import {
|
|
createDefaultGlobalConfig,
|
|
getGlobalConfigPath,
|
|
loadGlobalConfig,
|
|
} from "@aoagents/ao-core";
|
|
|
|
export type RemoteAuthCredentials = {
|
|
username: string;
|
|
password?: string;
|
|
};
|
|
|
|
const TOKEN_VERSION = "v1";
|
|
const TOKEN_TTL_MS = 5 * 60 * 1000;
|
|
let lastActiveCredentialKey: string | undefined;
|
|
|
|
function base64UrlEncode(input: string | Buffer): string {
|
|
return Buffer.from(input).toString("base64url");
|
|
}
|
|
|
|
function base64UrlDecode(input: string): string | null {
|
|
try {
|
|
return Buffer.from(input, "base64url").toString("utf-8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getTokenSecret(): string | undefined {
|
|
return process.env.AO_REMOTE_WS_TOKEN_SECRET?.trim() || undefined;
|
|
}
|
|
|
|
export function ensureRemoteWsTokenSecret(): string {
|
|
const existing = process.env.AO_REMOTE_WS_TOKEN_SECRET?.trim() || undefined;
|
|
if (existing) return existing;
|
|
const secret = randomBytes(32).toString("base64url");
|
|
process.env.AO_REMOTE_WS_TOKEN_SECRET = secret;
|
|
return secret;
|
|
}
|
|
|
|
export function rotateRemoteWsTokenSecret(): string {
|
|
const secret = randomBytes(32).toString("base64url");
|
|
process.env.AO_REMOTE_WS_TOKEN_SECRET = secret;
|
|
return secret;
|
|
}
|
|
|
|
function noteActiveCredentials(credentials: RemoteAuthCredentials): void {
|
|
const credentialKey = `${credentials.username}\0${credentials.password ?? ""}`;
|
|
if (lastActiveCredentialKey === undefined) {
|
|
lastActiveCredentialKey = credentialKey;
|
|
return;
|
|
}
|
|
if (lastActiveCredentialKey !== credentialKey) {
|
|
rotateRemoteWsTokenSecret();
|
|
lastActiveCredentialKey = credentialKey;
|
|
}
|
|
}
|
|
|
|
export function decodeBasicToken(
|
|
input: string | undefined | null,
|
|
): { username: string; password: string } | null {
|
|
if (!input) return null;
|
|
try {
|
|
const decoded = Buffer.from(input, "base64").toString("utf-8");
|
|
const separator = decoded.indexOf(":");
|
|
if (separator === -1) return null;
|
|
return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function readConfiguredRemoteAuth(): RemoteAuthCredentials {
|
|
const config = loadGlobalConfig(getGlobalConfigPath()) ?? createDefaultGlobalConfig();
|
|
const remoteAccess =
|
|
config.remoteAccess && typeof config.remoteAccess === "object"
|
|
? (config.remoteAccess as { username?: string; password?: string })
|
|
: undefined;
|
|
|
|
return {
|
|
username: remoteAccess?.username?.trim() || "ao",
|
|
password: remoteAccess?.password?.trim() || undefined,
|
|
};
|
|
}
|
|
|
|
export function activeRemoteAuth(initialConfiguredAuth?: RemoteAuthCredentials): RemoteAuthCredentials {
|
|
const configured = readConfiguredRemoteAuth();
|
|
if (
|
|
initialConfiguredAuth &&
|
|
(configured.username !== initialConfiguredAuth.username ||
|
|
configured.password !== initialConfiguredAuth.password)
|
|
) {
|
|
noteActiveCredentials(configured);
|
|
return configured;
|
|
}
|
|
|
|
const active = {
|
|
username: process.env.AO_REMOTE_AUTH_USER || initialConfiguredAuth?.username || configured.username,
|
|
password: process.env.AO_REMOTE_AUTH_PASSWORD || initialConfiguredAuth?.password || configured.password,
|
|
};
|
|
noteActiveCredentials(active);
|
|
return active;
|
|
}
|
|
|
|
export function createRemoteWsToken(credentials: RemoteAuthCredentials): string | undefined {
|
|
noteActiveCredentials(credentials);
|
|
const secret = getTokenSecret();
|
|
if (!secret) return undefined;
|
|
|
|
const payload = base64UrlEncode(
|
|
JSON.stringify({
|
|
u: credentials.username,
|
|
exp: Date.now() + TOKEN_TTL_MS,
|
|
n: randomBytes(12).toString("base64url"),
|
|
}),
|
|
);
|
|
const signature = createHmac("sha256", secret).update(payload).digest("base64url");
|
|
return `${TOKEN_VERSION}.${payload}.${signature}`;
|
|
}
|
|
|
|
export function verifyRemoteWsToken(
|
|
token: string | undefined | null,
|
|
expectedCredentials: RemoteAuthCredentials,
|
|
): boolean {
|
|
const secret = getTokenSecret();
|
|
if (!secret || !token) return false;
|
|
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return false;
|
|
const [, payload, signature] = parts;
|
|
if (!payload || !signature) return false;
|
|
|
|
const expectedSignature = createHmac("sha256", secret).update(payload).digest("base64url");
|
|
const provided = Buffer.from(signature);
|
|
const expectedSignatureBuffer = Buffer.from(expectedSignature);
|
|
if (
|
|
provided.length !== expectedSignatureBuffer.length ||
|
|
!timingSafeEqual(provided, expectedSignatureBuffer)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const rawPayload = base64UrlDecode(payload);
|
|
if (!rawPayload) return false;
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawPayload) as { u?: unknown; exp?: unknown };
|
|
return (
|
|
parsed.u === expectedCredentials.username &&
|
|
typeof parsed.exp === "number" &&
|
|
parsed.exp >= Date.now()
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function isBasicAuthHeaderAllowed(
|
|
authorization: string | string[] | undefined,
|
|
expected: RemoteAuthCredentials,
|
|
): boolean {
|
|
if (!expected.password) return true;
|
|
const header = Array.isArray(authorization) ? authorization[0] : (authorization ?? "");
|
|
const headerMatch = /^Basic\s+(.+)$/i.exec(header);
|
|
const credentials = decodeBasicToken(headerMatch?.[1]);
|
|
return credentials?.username === expected.username && credentials.password === expected.password;
|
|
}
|