@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
238 lines (237 loc) • 9.17 kB
JavaScript
/**
* Split-PKCE provisioning — minting a resident credential without ever holding
* one.
*
* The earlier design had the lender complete an authorization and hand the
* borrower a file containing a live access token and a refresh token. That file
* is copyable, re-sharable, and exists on disk in plaintext at both ends for as
* long as anyone forgets to delete it. Nothing about the lease constrains a
* credential someone else already copied.
*
* This splits the flow along the seam PKCE was designed for:
*
* 1. The **borrower** generates a verifier and sends only its SHA-256
* challenge, over its authenticated grant.
* 2. The **lender** authorizes in their own browser, against their own account,
* with the borrower's challenge in the URL.
* 3. The **authorization code** comes back to the lender and is relayed to the
* borrower.
* 4. The **borrower** exchanges code + verifier for tokens, on its own machine.
*
* The code is worthless to anyone who intercepts it: the token endpoint will not
* exchange it without the verifier, which never left the borrower. The lender
* never possesses a token for the credential it just authorized, so there is
* nothing for it to leak, re-send, or forget to delete.
*
* **What this module owns** is the lender's side of that conversation: the
* pending request, its binding to one grant, its expiry, and the rule that a
* code is handed over exactly once.
*
* @module proxy/shareProvisioning
*/
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { AsyncMutex } from "../utils/asyncMutex.js";
import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
const PROVISION_FILE = "proxy-share-provisioning.json";
/**
* How long a challenge stays claimable.
*
* Long enough for a lender to notice a request and finish a browser login,
* short enough that a challenge left lying around is not a standing invitation.
*/
export const PROVISION_REQUEST_TTL_MS = 900_000;
/** A base64url SHA-256 digest is always 43 characters, unpadded. */
const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
/** State is ours to choose; bound the shape so a peer cannot smuggle anything. */
const STATE_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
let customFilePath = null;
let cache = {};
let loaded = false;
const mutationMutex = new AsyncMutex();
export function initShareProvisioning(filePath) {
customFilePath = filePath;
cache = {};
loaded = false;
}
function getFilePath() {
return customFilePath ?? join(homedir(), ".neurolink", PROVISION_FILE);
}
function isRequest(value) {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value;
return (typeof candidate.grantId === "string" &&
typeof candidate.codeChallenge === "string" &&
typeof candidate.state === "string" &&
typeof candidate.expiresAt === "number" &&
typeof candidate.status === "string");
}
async function ensureLoaded(options = {}) {
if (loaded && !options.force) {
return;
}
try {
const parsed = JSON.parse(await readFile(getFilePath(), "utf8"));
cache = Object.fromEntries(Object.entries(parsed?.requests ?? {}).filter((entry) => isRequest(entry[1])));
}
catch {
cache = {};
}
loaded = true;
}
async function persist() {
const file = { schemaVersion: 1, requests: cache };
await writeJsonSnapshotAtomically(getFilePath(), file);
}
/**
* Narrow an open/authorize outcome to its failing half.
*
* One of the package's build steps compiles without `strictNullChecks`, where a
* boolean discriminant does not narrow. Callers reading `.reason` need this.
*/
export function isProvisionFailure(outcome) {
return !outcome.ok;
}
/** Is this request still live? Expiry and consumption both retire it. */
export function isProvisionRequestOpen(request, now = Date.now()) {
return request.status !== "consumed" && request.expiresAt > now;
}
/**
* Record a borrower's challenge.
*
* One open request per grant: a second one replaces the first rather than
* queueing, because the borrower that just asked is the one waiting, and an
* abandoned challenge should not be claimable later by whoever finds it.
*
* Rejects anything that is not a well-formed S256 challenge. The lender is about
* to put this string into an authorization URL against its own account, so it
* is not the place to be relaxed about input.
*/
export async function openProvisionRequest(args) {
if (!CHALLENGE_PATTERN.test(args.codeChallenge)) {
return {
ok: false,
reason: "code_challenge must be a base64url S256 digest",
};
}
if (!STATE_PATTERN.test(args.state)) {
return { ok: false, reason: "state must be 16-128 base64url characters" };
}
const now = args.now ?? Date.now();
return mutationMutex.runExclusive(async () => {
await ensureLoaded({ force: true });
const request = {
schemaVersion: 1,
grantId: args.grantId,
codeChallenge: args.codeChallenge,
challengeMethod: "S256",
state: args.state,
requestedAt: now,
expiresAt: now + PROVISION_REQUEST_TTL_MS,
status: "pending",
};
cache[args.grantId] = request;
await persist();
return { ok: true, request };
});
}
/** The open request for a grant, if there is one. */
export async function getProvisionRequest(grantId, now = Date.now()) {
await ensureLoaded({ force: true });
const request = cache[grantId];
if (!request || !isProvisionRequestOpen(request, now)) {
return undefined;
}
return request;
}
/** Every open request, for a lender deciding which to authorize. */
export async function listProvisionRequests(now = Date.now()) {
await ensureLoaded({ force: true });
return Object.values(cache).filter((request) => isProvisionRequestOpen(request, now));
}
/**
* Attach the authorization code the lender's browser produced.
*
* The code is bound to the grant that asked for it and to the account the lender
* authorized against — the same account the drift audit will later reconcile —
* so a code cannot be redirected to a different grant after the fact.
*/
export async function authorizeProvisionRequest(args) {
const now = args.now ?? Date.now();
const code = args.code.trim();
if (!code) {
return { ok: false, reason: "no authorization code" };
}
return mutationMutex.runExclusive(async () => {
await ensureLoaded({ force: true });
const request = cache[args.grantId];
if (!request) {
return { ok: false, reason: "no provisioning request" };
}
if (!isProvisionRequestOpen(request, now)) {
return { ok: false, reason: "the request has expired" };
}
const authorized = {
...request,
status: "authorized",
code,
authorizedAt: now,
...(args.accountLabel ? { accountLabel: args.accountLabel } : {}),
};
cache[args.grantId] = authorized;
await persist();
return { ok: true, request: authorized };
});
}
/**
* Hand the code to the borrower — once.
*
* Single-use is the whole binding: an authorization code that could be claimed
* twice would let anyone who replayed the call mint a second credential on the
* lender's account. Consumption is recorded before the value is returned.
*/
export async function claimProvisionRequest(grantId, now = Date.now()) {
return mutationMutex.runExclusive(async () => {
await ensureLoaded({ force: true });
const request = cache[grantId];
if (!request || !isProvisionRequestOpen(request, now)) {
return { status: "none" };
}
if (request.status !== "authorized" || !request.code) {
return { status: "pending" };
}
// Drop the code rather than carrying it through the spread: it has been
// handed over, it is single-use, and `persist()` would otherwise write a
// live authorization code to disk for the life of the record.
const { code: _claimed, ...spent } = request;
cache[grantId] = { ...spent, status: "consumed", claimedAt: now };
await persist();
return {
status: "ready",
code: request.code,
state: request.state,
};
});
}
/** Drop a grant's request — used when the grant itself goes away. */
export async function clearProvisionRequest(grantId) {
await mutationMutex.runExclusive(async () => {
await ensureLoaded({ force: true });
if (cache[grantId]) {
delete cache[grantId];
await persist();
}
});
}
/**
* A state value for a borrower to send alongside its challenge.
*
* Kept here so both ends agree on the shape `STATE_PATTERN` will accept.
*/
export function generateProvisionState() {
return randomBytes(24).toString("base64url");
}