src/services/siwc-session.mjs
Rama main · réplica de lectura del repositorio original.
import * as fs from 'node:fs/promises';
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import {
lockDownLocalPath, recallWindowsLockdown, refreshWindowsLockdown, rememberWindowsLockdown, retryWindowsFileSharing,
} from '../infrastructure/local-process.js';
import { createLocalJWKSet, jwtVerify } from 'jose';
import { acquireLocalIntegrationLifecycleLock } from './local-integration-lifecycle-lock.js';
const ISSUER = 'https://auth.openai.com';
const RESOURCE = 'https://api.openai.com/v1';
const MAX_FILE = 128 * 1024;
export const SIWC_LOCK_OPERATION_TIMEOUT_MS = 120_000;
export const SIWC_LOCK_LEASE_MS = 600_000;
export const SIWC_LOCK_WAIT_TIMEOUT_MS = 150_000;
const monotonicMilliseconds = () => Number(process.hrtime.bigint() / 1_000_000n);
const TERMINAL = new Set(['invalid_grant', 'invalid_refresh_token', 'token_expired', 'refresh_token_expired', 'refresh_token_invalidated', 'refresh_token_reused']);
const pause = ms => new Promise(done => setTimeout(done, ms));
export async function readSiwcJson(response) {
const reader = response?.body?.getReader?.();
if (!reader) throw new Error('Invalid OpenAI response.');
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_FILE) {
await reader.cancel();
throw new Error('OpenAI response exceeded the size limit.');
}
chunks.push(value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, total)));
} catch {
throw new Error('Invalid or oversized OpenAI response.');
} finally {
reader.releaseLock();
}
}
const settings = deps => ({ fileSystem: deps?.fileSystem ?? fs, fetchImpl: deps?.fetchImpl ?? fetch, now: deps?.now ?? Date.now, platform: deps?.platform ?? process.platform, lockDownPath: deps?.lockDownPath ?? lockDownLocalPath,
getProcessIdentity: deps?.getProcessIdentity, getPidNamespaceIdentity: deps?.getPidNamespaceIdentity,
waitForLock: deps?.waitForLock ?? pause, monotonicNow: deps?.monotonicNow ?? monotonicMilliseconds,
signal: deps?.signal, callerSignal: deps && Object.hasOwn(deps, 'callerSignal') ? deps.callerSignal : deps?.signal,
holderSignal: deps?.holderSignal, checkLock: deps?.checkLock,
scheduleTimeout: deps?.scheduleTimeout ?? setTimeout, cancelTimeout: deps?.cancelTimeout ?? clearTimeout });
const valid = (value, pattern, name) => {
if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError(`Invalid SIWC ${name}.`);
return value;
};
export const validateSiwcRegistrationId = value => valid(value, /^[A-Za-z0-9_-]{8,64}$/u, 'registration ID');
export const validateSiwcRuntimeId = value => valid(value, /^[A-Za-z0-9_-]{1,64}$/u, 'runtime ID');
export const validateSiwcHostId = value => valid(value, /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu, 'host ID');
export const validateSiwcClientId = value => valid(value, /^[!-~]{1,256}$/u, 'issued client ID');
const assertGeneration = value => valid(value, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu, 'generation');
const assertTarget = target => ({ hostId: validateSiwcHostId(target?.hostId), runtimeId: validateSiwcRuntimeId(target?.runtimeId) });
export function resolveSiwcStorageRoot({ env = process.env, homeDirectory = homedir() } = {}) {
const root = env.N8N_OPENAI_OAUTH_HOME || join(homeDirectory, '.n8n-openai-oauth');
if (typeof root !== 'string' || !isAbsolute(root) || root.includes('\0') || resolve(root) !== root) throw new TypeError('Invalid SIWC storage root.');
return root;
}
const rootOf = storageRoot => {
const root = storageRoot ?? resolveSiwcStorageRoot();
if (typeof root !== 'string' || !isAbsolute(root) || root.includes('\0') || resolve(root) !== root) throw new TypeError('Invalid SIWC storage root.');
return root;
};
const fileOf = (root, id) => join(root, 'registrations', `${validateSiwcRegistrationId(id)}.json`);
const uid = () => typeof process.getuid === 'function' ? process.getuid() : null;
// An unchanged inode this process locked down itself skips the repeat Windows ACL
// check; foreign, replaced or changed inodes do not.
async function verifyWindowsAcl(path, kind, stat, contents, cfg) {
if (contents !== null && recallWindowsLockdown(cfg.lockDownPath, stat, contents)) return;
await cfg.lockDownPath(path, { platform: cfg.platform, kind, verifyOnly: true });
if (contents !== null) refreshWindowsLockdown(cfg.lockDownPath, stat, contents);
}
async function assertSafePath(path, kind, cfg, { windowsAclChecked = false } = {}) {
const { fileSystem: io, platform } = cfg;
const stat = await io.lstat(path);
if (stat.isSymbolicLink() || (kind === 'file' ? !stat.isFile() || stat.nlink !== 1 : !stat.isDirectory())) throw new Error('Unsafe SIWC storage entry.');
if (platform === 'win32') { if (!windowsAclChecked) await verifyWindowsAcl(path, kind, stat, '', cfg); }
else if ((uid() !== null && stat.uid !== uid()) || (stat.mode & (kind === 'file' ? 0o077 : 0o077)) !== 0) throw new Error('Insecure SIWC storage permissions.');
return stat;
}
async function directory(path, cfg) {
cfg.checkLock?.();
let created = true;
try { await cfg.fileSystem.mkdir(path, { mode: 0o700 }); }
catch (error) { if (error?.code !== 'EEXIST') throw error; created = false; }
const stat = await cfg.fileSystem.lstat(path);
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('Unsafe SIWC storage entry.');
if (cfg.platform !== 'win32') {
await assertSafePath(path, 'directory', cfg);
return;
}
if (recallWindowsLockdown(cfg.lockDownPath, stat, '')) return;
// Rewriting a directory DACL re-propagates to every child and races other processes
// using them, so an existing directory that already verifies owner-only is kept as is.
if (!created) {
let verified = false;
try {
await cfg.lockDownPath(path, { platform: cfg.platform, kind: 'directory', verifyOnly: true });
verified = true;
} catch { /* Repair it below, exactly like a new directory. */ }
if (verified) {
refreshWindowsLockdown(cfg.lockDownPath, await assertSafePath(path, 'directory', cfg, { windowsAclChecked: true }), '');
return;
}
}
await cfg.lockDownPath(path, { platform: cfg.platform, kind: 'directory' });
// The lockdown reads its owner-only DACL back, so no separate verify-only check follows.
rememberWindowsLockdown(cfg.lockDownPath, await assertSafePath(path, 'directory', cfg, { windowsAclChecked: true }), '');
}
async function ensureRoot(root, cfg) {
await directory(root, cfg);
await directory(join(root, 'registrations'), cfg);
}
async function readFile(path, cfg, missing = null) {
cfg.checkLock?.();
let stat;
// The Windows ACL check runs below, once the contents are known and before they are used.
try { stat = await assertSafePath(path, 'file', cfg, { windowsAclChecked: true }); }
catch (error) { if (error?.code === 'ENOENT') return missing; throw error; }
if (stat.size > MAX_FILE || stat.size === 0) throw new Error('Invalid SIWC storage file.');
const handle = await cfg.fileSystem.open(path, 'r');
try {
const opened = await handle.stat();
if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error('SIWC storage changed during read.');
const bytes = await handle.readFile();
if (bytes.length !== stat.size || bytes.length > MAX_FILE) throw new Error('Invalid SIWC storage file.');
if (cfg.platform === 'win32') await verifyWindowsAcl(path, 'file', stat, bytes, cfg);
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
catch { throw new Error('Invalid SIWC storage file.'); }
} finally { await handle.close(); }
}
async function atomicFile(path, value, cfg, beforeRename = () => {}) {
cfg.checkLock?.();
const contents = Buffer.from(JSON.stringify(value));
if (contents.length === 0 || contents.length > MAX_FILE) throw new Error('SIWC storage record is too large.');
const temp = `${path}.${randomUUID()}.tmp`;
let created = false;
try {
const handle = await cfg.fileSystem.open(temp, 'wx', 0o600);
created = true;
try {
if (cfg.platform === 'win32') await cfg.lockDownPath(temp, { platform: cfg.platform, kind: 'file' });
cfg.checkLock?.();
await handle.writeFile(contents);
await handle.sync();
} finally { await handle.close(); }
// The Windows lockdown above already read the staged file's owner-only DACL back.
const staged = await assertSafePath(temp, 'file', cfg, { windowsAclChecked: true });
if (cfg.platform === 'win32') rememberWindowsLockdown(cfg.lockDownPath, staged, contents);
try {
const existing = await assertSafePath(path, 'file', cfg, { windowsAclChecked: true });
if (cfg.platform === 'win32') {
await verifyWindowsAcl(path, 'file', existing,
existing.size > 0 && existing.size <= MAX_FILE ? await cfg.fileSystem.readFile(path) : null, cfg);
}
} catch (error) { if (error?.code !== 'ENOENT') throw error; }
beforeRename();
cfg.checkLock?.();
await retryWindowsFileSharing(() => cfg.fileSystem.rename(temp, path), { platform: cfg.platform });
if (cfg.platform !== 'win32') {
const directoryHandle = await cfg.fileSystem.open(dirname(path), 'r');
try { await directoryHandle.sync(); } finally { await directoryHandle.close(); }
}
} finally { if (created) await cfg.fileSystem.rm(temp, { force: true }); }
}
const lockUnavailable = () => Object.assign(new Error('SIWC session lock is unavailable. Retry later.'),
{ status: 503, recovery: 'retry-later', code: 'siwc_lock_unavailable' });
async function releaseSessionLock(release, cfg) {
const deadline = cfg.monotonicNow() + SIWC_LOCK_WAIT_TIMEOUT_MS;
for (let attempt = 0; attempt < 3000; attempt++) {
try { await release(); return; }
catch (error) {
if (!['RELMIO_LOCK_BUSY', 'RELMIO_LOCK_CHANGED', 'EAGAIN', 'EBUSY', 'EINTR', 'EIO'].includes(error?.code) ||
attempt === 2999 || cfg.monotonicNow() >= deadline) throw lockUnavailable();
await cfg.waitForLock(50);
}
}
}
async function locked(root, registrationId, cfg, operation) {
validateSiwcRegistrationId(registrationId);
await ensureRoot(root, cfg);
const lockPath = join(root, 'registrations', `${registrationId}.lock`);
const fileSystem = { ...cfg.fileSystem, async lstat(path) {
const stat = await cfg.fileSystem.lstat(path);
if (stat.isSymbolicLink() || stat.isFile() && (stat.nlink < 1 || stat.nlink > 2) ||
cfg.platform !== 'win32' && uid() !== null && stat.uid !== uid())
throw new Error('Unsafe SIWC lock storage.');
return stat;
} };
let release;
const waitDeadline = cfg.monotonicNow() + SIWC_LOCK_WAIT_TIMEOUT_MS;
for (let i = 0; i < 3000 && cfg.monotonicNow() < waitDeadline; i++) {
cfg.checkLock?.();
cfg.callerSignal?.throwIfAborted();
try {
release = await acquireLocalIntegrationLifecycleLock({
fileSystem, lockPath, platform: cfg.platform, lockDownPath: cfg.lockDownPath,
getProcessIdentity: cfg.getProcessIdentity, getPidNamespaceIdentity: cfg.getPidNamespaceIdentity,
reclaimIncomplete: false, atomicPublication: true, leaseMs: SIWC_LOCK_LEASE_MS, leaseNow: cfg.monotonicNow,
label: 'SIWC session lock', releaseWaitMs: SIWC_LOCK_WAIT_TIMEOUT_MS,
});
break;
} catch (error) {
if (!['RELMIO_LOCK_BUSY', 'RELMIO_LOCK_CHANGED', 'RELMIO_LOCK_IDENTITY_UNAVAILABLE'].includes(error?.code))
throw lockUnavailable();
if (i < 2999 && cfg.monotonicNow() < waitDeadline) await cfg.waitForLock(50);
}
}
if (!release) throw lockUnavailable();
const controller = new AbortController();
const holderSignal = cfg.holderSignal ? AbortSignal.any([cfg.holderSignal, controller.signal]) : controller.signal;
const callerSignal = cfg.callerSignal;
const signal = callerSignal ? AbortSignal.any([callerSignal, holderSignal]) : holderSignal;
const deadline = release.acquiredAt + SIWC_LOCK_OPERATION_TIMEOUT_MS;
const deadlineFailure = () => Object.assign(new Error('SIWC session operation exceeded its deadline. Retry later.'),
{ status: 503, recovery: 'retry-later', code: 'siwc_lock_deadline' });
const checkLock = () => {
cfg.checkLock?.();
if (cfg.monotonicNow() >= deadline && !controller.signal.aborted) controller.abort(deadlineFailure());
holderSignal.throwIfAborted();
};
const activeCfg = { ...cfg, signal, holderSignal, callerSignal, checkLock };
const subscriptions = [];
const cancellations = [holderSignal, callerSignal].filter(Boolean).map(signal => new Promise((_, reject) => {
const onAbort = () => reject(signal.reason);
subscriptions.push({ signal, onAbort });
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
}));
const timer = cfg.scheduleTimeout(() => controller.abort(deadlineFailure()), Math.max(0, deadline - cfg.monotonicNow()));
const operationResult = Promise.resolve().then(() => {
checkLock();
callerSignal?.throwIfAborted();
return operation(activeCfg);
}).then(result => {
checkLock();
return result;
}).finally(async () => {
cfg.cancelTimeout(timer);
for (const { signal, onAbort } of subscriptions) signal.removeEventListener('abort', onAbort);
controller.abort(deadlineFailure());
await releaseSessionLock(release, cfg);
});
// Caller cancellation can return early, but the owned operation retains the lock until it settles.
return Promise.race([operationResult, ...cancellations]).then(result => {
callerSignal?.throwIfAborted();
return result;
});
}
const CONSENT_NOTICE = Object.freeze({
'local-n8n': 'siwc-local-2026-10-04',
'vps-n8n': 'siwc-vps-2026-10-04',
});
function consentShape(value) {
return Boolean(value && typeof value === 'object' &&
Object.keys(value).sort().join(',') === 'acceptedAt,noticeVersion' &&
typeof value.acceptedAt === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u.test(value.acceptedAt) &&
!Number.isNaN(Date.parse(value.acceptedAt)));
}
export function validateSiwcBackgroundConsent(value, { target } = {}) {
if (!Object.hasOwn(CONSENT_NOTICE, target) || !consentShape(value) ||
value.noticeVersion !== CONSENT_NOTICE[target]) throw new TypeError('Invalid SIWC background consent.');
return value;
}
function validConsent(value) {
return value === false || consentShape(value) &&
Object.values(CONSENT_NOTICE).includes(value.noticeVersion);
}
function validateHandoffBinding(binding) {
if (!binding || typeof binding !== 'object' ||
Object.keys(binding).sort().join(',') !== 'backgroundConsent,clientId,generation,registrationId,source,target' ||
!binding.source || typeof binding.source !== 'object' ||
Object.keys(binding.source).sort().join(',') !== 'hostId,runtimeId' ||
!binding.target || typeof binding.target !== 'object' ||
Object.keys(binding.target).sort().join(',') !== 'hostId,runtimeId' ||
!validConsent(binding.backgroundConsent)) throw new Error('Invalid SIWC handoff binding.');
validateSiwcRegistrationId(binding.registrationId);
validateSiwcClientId(binding.clientId);
assertGeneration(binding.generation);
assertTarget(binding.source);
assertTarget(binding.target);
return binding;
}
function validateHandoffReceipt(receipt) {
if (!receipt || typeof receipt !== 'object' ||
Object.keys(receipt).sort().join(',') !== 'binding,handoffId,proof' ||
typeof receipt.proof !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(receipt.proof))
throw new Error('Invalid SIWC handoff receipt.');
assertGeneration(receipt.handoffId);
validateHandoffBinding(receipt.binding);
return receipt;
}
function validatePending(record, id) {
if (record?.schemaVersion !== 1 || record.kind !== 'pending' || record.registrationId !== id ||
record.label !== 'ChatGPT sign-in pending' || !Number.isSafeInteger(record.createdAt) ||
record.identity !== undefined || record.session !== undefined ||
record.planEnabled !== undefined || record.handoff !== undefined) throw new Error('Invalid pending SIWC registration.');
validateSiwcRegistrationId(id);
validateSiwcClientId(record.clientId);
if (record.clientId === 'dynamic_agent_client') throw new Error('Invalid pending SIWC client.');
assertGeneration(record.generation);
validateSiwcHostId(record.owner?.hostId);
validateSiwcRuntimeId(record.owner?.runtimeId);
return record;
}
function pendingAccount(record) {
return { registrationId: record.registrationId, label: record.label, identity: 'unverified',
session: 'signed-out', planPermission: 'not-granted', planEnabled: false,
ownership: 'owned', generation: record.generation,
ownerHostId: record.owner.hostId, ownerRuntimeId: record.owner.runtimeId,
needsPlanWelcome: false };
}
function validateRecord(record, id) {
if (record?.schemaVersion !== 1 || record.registrationId !== id ||
typeof record.label !== 'string' || !record.label || record.label.length > 256 ||
/[\u0000-\u001f\u007f]/u.test(record.label) ||
!['owned', 'handoff-pending', 'transferred'].includes(record.handoff?.state) ||
typeof record.planEnabled !== 'boolean' || typeof record.planAcknowledged !== 'boolean' ||
!validConsent(record.backgroundConsent)) throw new Error('Invalid SIWC registration record.');
validateSiwcRegistrationId(record.registrationId);
validateSiwcClientId(record.clientId);
if (record.clientId === 'dynamic_agent_client') throw new Error('Invalid SIWC issued client ID.');
assertGeneration(record.generation);
validateSiwcHostId(record.owner?.hostId);
validateSiwcRuntimeId(record.owner?.runtimeId);
if (record.identity?.issuer !== ISSUER || typeof record.identity?.subject !== 'string' ||
!record.identity.subject || record.identity.subject.length > 512 ||
record.identity.email !== undefined &&
(typeof record.identity.email !== 'string' || record.identity.email.length > 256)) throw new Error('Invalid SIWC identity.');
const session = record.session;
if (!session || typeof session !== 'object' || Array.isArray(session) ||
(session.accessToken !== undefined && (typeof session.accessToken !== 'string' || !session.accessToken || session.accessToken.length > 8192)) ||
(session.refreshToken !== undefined && (typeof session.refreshToken !== 'string' || !session.refreshToken || session.refreshToken.length > 8192)) ||
(session.idToken !== undefined && (typeof session.idToken !== 'string' || !session.idToken || session.idToken.length > 16384)) ||
(session.scopes !== undefined && (!Array.isArray(session.scopes) || session.scopes.length > 64 ||
session.scopes.some(scope => typeof scope !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/u.test(scope)))) ||
(session.accessToken !== undefined && (!['Bearer', 'bearer'].includes(session.tokenType) ||
!Number.isSafeInteger(session.receivedAt) || !Number.isSafeInteger(session.expiresIn) ||
!Number.isSafeInteger(session.expiresAt) || session.expiresAt !== session.receivedAt + session.expiresIn * 1000)) ||
(session.identityOnly === true && session.accessToken !== undefined) ||
(session.reauthorize === true && (session.accessToken !== undefined || session.refreshToken !== undefined)) ||
(session.refreshUncertain !== undefined && typeof session.refreshUncertain !== 'boolean') ||
(session.refreshUncertain === true && record.planEnabled) ||
(session.verificationPending !== undefined && typeof session.verificationPending !== 'boolean') ||
(session.verificationPending === true && (!session.refreshUncertain ||
typeof session.pendingIdToken !== 'boolean' || typeof session.resumePlanEnabled !== 'boolean' ||
session.pendingIdToken && !session.idToken)) ||
(record.planEnabled && (!session.accessToken || !session.scopes?.includes('chatgpt.tokens.use.direct')))) throw new Error('Invalid SIWC session.');
if (record.handoff.state === 'handoff-pending') {
const binding = record.handoff.binding;
if (typeof record.handoff.exported !== 'boolean' ||
typeof record.handoff.handoffId !== 'string' || !/^[0-9a-f-]{36}$/iu.test(record.handoff.handoffId) ||
typeof record.handoff.proofSecret !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(record.handoff.proofSecret) ||
binding?.registrationId !== record.registrationId || binding.clientId !== record.clientId ||
binding.source?.hostId !== record.owner.hostId || binding.source.runtimeId !== record.owner.runtimeId)
throw new Error('Invalid SIWC handoff.');
validateHandoffBinding(binding);
}
if (record.handoff.state === 'transferred' &&
(record.handoff.handoffId !== undefined || record.handoff.receipt !== undefined)) {
const receipt = validateHandoffReceipt(record.handoff.receipt);
if (receipt.handoffId !== record.handoff.handoffId ||
receipt.binding.registrationId !== record.registrationId ||
receipt.binding.clientId !== record.clientId ||
receipt.binding.source.hostId !== record.owner.hostId ||
receipt.binding.source.runtimeId !== record.owner.runtimeId ||
record.session.accessToken || record.session.refreshToken || record.session.idToken)
throw new Error('Invalid transferred SIWC handoff.');
}
if (record.acceptedHandoff !== undefined) {
const accepted = record.acceptedHandoff;
if (!accepted || typeof accepted !== 'object' ||
Object.keys(accepted).sort().join(',') !== 'binding,handoffId,receipt')
throw new Error('Invalid accepted SIWC handoff.');
validateHandoffReceipt(accepted.receipt);
assertGeneration(accepted.handoffId);
validateHandoffBinding(accepted.binding);
if (accepted.binding.registrationId !== record.registrationId ||
accepted.binding.clientId !== record.clientId ||
accepted.binding.target.hostId !== record.owner.hostId ||
accepted.binding.target.runtimeId !== record.owner.runtimeId ||
accepted.receipt.handoffId !== accepted.handoffId ||
JSON.stringify(accepted.receipt.binding) !== JSON.stringify(accepted.binding) ||
typeof accepted.receipt.proof !== 'string' ||
!/^[A-Za-z0-9_-]{43}$/u.test(accepted.receipt.proof))
throw new Error('Invalid accepted SIWC receipt.');
}
return record;
}
function account(record) {
return {
registrationId: record.registrationId, label: record.label,
...(record.identity.email ? { email: record.identity.email } : {}),
identity: 'verified',
session: record.session?.reauthorize || record.session?.refreshUncertain ? 'reauthorize'
: record.session?.accessToken || record.session?.identityOnly ? 'connected' : 'signed-out',
planPermission: record.session?.accessToken && record.session?.scopes?.includes('chatgpt.tokens.use.direct') ? 'granted' : 'not-granted',
planEnabled: !!record.planEnabled,
ownership: record.handoff?.state ?? 'owned', generation: record.generation,
ownerHostId: record.owner.hostId, ownerRuntimeId: record.owner.runtimeId,
needsPlanWelcome: !!record.planEnabled && !record.planAcknowledged,
};
}
export async function ensureSiwcHost({ storageRoot, runtimeId }, deps = {}) {
const root = rootOf(storageRoot), cfg = settings(deps);
validateSiwcRuntimeId(runtimeId);
return locked(root, 'hostidentity', cfg, async cfg => {
const path = join(root, 'host.json');
let host = await readFile(path, cfg);
if (!host) {
host = { schemaVersion: 1, hostId: `urn:uuid:${randomUUID()}` };
await atomicFile(path, host, cfg);
}
if (host.schemaVersion !== 1) throw new Error('Invalid SIWC host identity.');
return { hostId: validateSiwcHostId(host.hostId), runtimeId };
});
}
export async function readSiwcHost({ storageRoot, runtimeId }, deps = {}) {
const root = rootOf(storageRoot), cfg = settings(deps);
validateSiwcRuntimeId(runtimeId);
await assertSafePath(root, 'directory', cfg);
const host = await readFile(join(root, 'host.json'), cfg);
if (host?.schemaVersion !== 1) throw new Error('SIWC host identity is missing or invalid.');
return { hostId: validateSiwcHostId(host.hostId), runtimeId };
}
export async function readRegistration({ storageRoot, registrationId }, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
validateSiwcRegistrationId(registrationId);
await ensureRoot(root, cfg);
const record = await readFile(fileOf(root, registrationId), cfg);
if (record?.kind === 'pending') {
validatePending(record, registrationId);
return null;
}
return record ? validateRecord(record, registrationId) : null;
}
export async function readPendingSiwcRegistration({ storageRoot, registrationId }, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
validateSiwcRegistrationId(registrationId);
await ensureRoot(root, cfg);
const record = await readFile(fileOf(root, registrationId), cfg);
if (!record) return null;
return record.kind === 'pending' ? validatePending(record, registrationId) : null;
}
function viewOf(record, registrationId) {
return record.kind === 'pending'
? pendingAccount(validatePending(record, registrationId))
: account(validateRecord(record, registrationId));
}
export async function readRegistrationView({ storageRoot, registrationId }, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
validateSiwcRegistrationId(registrationId);
await ensureRoot(root, cfg);
const record = await readFile(fileOf(root, registrationId), cfg);
return record ? viewOf(record, registrationId) : null;
}
export async function listRegistrations({ storageRoot } = {}, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
await ensureRoot(root, cfg);
const entries = await cfg.fileSystem.readdir(join(root, 'registrations'));
const result = [];
for (const name of entries) {
if (!name.endsWith('.json')) continue;
const id = name.slice(0, -5);
validateSiwcRegistrationId(id);
const record = await readFile(fileOf(root, id), cfg);
if (record) result.push(viewOf(record, id));
}
return result;
}
export async function getSelectedRegistration({ storageRoot } = {}, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
await ensureRoot(root, cfg);
const selected = await readFile(join(root, 'selection.json'), cfg);
if (!selected) return null;
validateSiwcRegistrationId(selected.registrationId);
const account = await readRegistration({ storageRoot: root, registrationId: selected.registrationId }, deps);
return account?.handoff.state === 'owned' ? selected.registrationId : null;
}
export async function selectRegistration({ storageRoot, registrationId }, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
const account = await readRegistration({ storageRoot: root, registrationId }, deps);
if (!account || account.handoff.state !== 'owned') throw new Error('The SIWC registration cannot be selected.');
await ensureRoot(root, cfg);
await atomicFile(join(root, 'selection.json'), { registrationId }, cfg);
return registrationId;
}
async function mutate({ storageRoot, registrationId }, deps, fn, beforeCommit, allowPending = false) {
const cfg = settings(deps), root = rootOf(storageRoot);
return locked(root, registrationId, cfg, async cfg => {
const path = fileOf(root, registrationId);
const previous = await readFile(path, cfg);
let current = null;
if (previous) current = previous.kind === 'pending' && allowPending
? validatePending(previous, registrationId) : validateRecord(previous, registrationId);
if (current) {
const host = await readSiwcHost({ storageRoot: root, runtimeId: current.owner.runtimeId }, deps);
if (host.hostId !== current.owner.hostId) throw new Error('SIWC registration belongs to another host.');
}
const outcome = await fn(current, cfg, root);
if (!outcome) return null;
const next = { ...outcome, generation: randomUUID() };
validateRecord(next, registrationId);
await atomicFile(path, next, cfg, beforeCommit);
return account(next);
});
}
async function rejectDuplicateClient(root, registrationId, clientId, cfg) {
for (const name of await cfg.fileSystem.readdir(join(root, 'registrations'))) {
if (!name.endsWith('.json')) continue;
const otherId = validateSiwcRegistrationId(name.slice(0, -5));
if (otherId === registrationId) continue;
const other = await readFile(fileOf(root, otherId), cfg);
if (other && (other.kind === 'pending' ? validatePending(other, otherId) : validateRecord(other, otherId)).clientId === clientId)
throw new Error('This issued ChatGPT client already belongs to a different registration.');
}
}
export async function reserveSiwcClient({ storageRoot, registrationId = randomUUID(), clientId, runtimeId }, deps = {}) {
validateSiwcRegistrationId(registrationId);
validateSiwcClientId(clientId);
if (clientId === 'dynamic_agent_client') throw new Error('An issued SIWC client ID is required.');
const cfg = settings(deps), root = rootOf(storageRoot);
const host = await readSiwcHost({ storageRoot: root, runtimeId }, deps);
return locked(root, 'identitymap', cfg, async cfg => {
await rejectDuplicateClient(root, registrationId, clientId, cfg);
return locked(root, registrationId, cfg, async cfg => {
const path = fileOf(root, registrationId);
if (await readFile(path, cfg)) throw new Error('SIWC registration already exists.');
const pending = { schemaVersion: 1, kind: 'pending', registrationId,
clientId, label: 'ChatGPT sign-in pending', generation: randomUUID(),
createdAt: cfg.now(), owner: host };
validatePending(pending, registrationId);
await atomicFile(path, pending, cfg);
return pending;
});
});
}
function normalizeTokens(tokens, now, old) {
if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens) ||
!Number.isSafeInteger(now) || now < 0 ||
(tokens.id_token !== undefined && (typeof tokens.id_token !== 'string' || !tokens.id_token || tokens.id_token.length > 16384)) ||
(tokens.refresh_token !== undefined && (typeof tokens.refresh_token !== 'string' || !tokens.refresh_token || tokens.refresh_token.length > 8192)) ||
(old && !tokens.refresh_token) ||
(tokens.earliest_refresh_at !== undefined && !(typeof tokens.earliest_refresh_at === 'string' && tokens.earliest_refresh_at.length <= 256 ||
typeof tokens.earliest_refresh_at === 'number' && Number.isFinite(tokens.earliest_refresh_at)))) throw new Error('Invalid SIWC token response.');
const hasAccess = typeof tokens.access_token === 'string' && tokens.access_token.length > 0 && tokens.access_token.length <= 8192;
if (tokens.access_token !== undefined && !hasAccess ||
(hasAccess && (!Number.isSafeInteger(tokens.expires_in) || tokens.expires_in < 1 || tokens.expires_in > 86400 ||
!['Bearer', 'bearer'].includes(tokens.token_type) || typeof tokens.scope !== 'string' || tokens.scope.length > 2048)) ||
(!hasAccess && old)) throw new Error('Invalid SIWC token response.');
const scopes = (tokens.scope ?? '').split(/\s+/u).filter(Boolean);
if (scopes.length > 64 || scopes.some(scope => !/^[A-Za-z0-9_.:-]{1,128}$/u.test(scope)) ||
(!hasAccess && scopes.includes('chatgpt.tokens.use.direct'))) throw new Error('Invalid SIWC granted scopes.');
return { ...(hasAccess ? { accessToken: tokens.access_token, tokenType: tokens.token_type,
expiresIn: tokens.expires_in, expiresAt: now + tokens.expires_in * 1000 } : { identityOnly: true }),
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
...(tokens.id_token ?? old?.idToken ? { idToken: tokens.id_token ?? old.idToken } : {}),
scopes, receivedAt: now,
...(tokens.earliest_refresh_at === undefined ? {} : { earliestRefreshAt: tokens.earliest_refresh_at }) };
}
export async function commitAuthorization({ storageRoot, registrationId = randomUUID(), expectedGeneration, clientId, identity, tokens, runtimeId, signal }, deps = {}) {
validateSiwcClientId(clientId);
validateSiwcRuntimeId(runtimeId);
if (identity?.issuer !== ISSUER || typeof identity?.subject !== 'string' || !identity.subject || identity.subject.length > 512 ||
(identity.email !== undefined && (typeof identity.email !== 'string' || identity.email.length > 256))) throw new Error('Invalid verified SIWC identity.');
const host = await ensureSiwcHost({ storageRoot, runtimeId }, deps);
const cfg = settings(deps), root = rootOf(storageRoot);
return locked(root, 'identitymap', cfg, async cfg => {
await rejectDuplicateClient(root, registrationId, clientId, cfg);
return mutate({ storageRoot: root, registrationId }, cfg, (old, currentCfg) => {
if (old && (old.clientId !== clientId || old.owner.hostId !== host.hostId ||
old.owner.runtimeId !== runtimeId || old.generation !== expectedGeneration ||
old.kind !== 'pending' && (old.identity.issuer !== identity.issuer ||
old.identity.subject !== identity.subject || old.handoff.state !== 'owned')))
throw new Error('SIWC registration changed; start a fresh sign-in.');
if (!old && expectedGeneration !== undefined) throw new Error('Unknown SIWC registration.');
const session = normalizeTokens(tokens, currentCfg.now());
// An established false setting may be an opt-out, so only the first
// verified authorization can choose the enabled default.
const firstAuthorization = !old || old.kind === 'pending';
return { schemaVersion: 1, registrationId,
label: old?.kind === 'pending' ? identity.email ?? `ChatGPT ${registrationId.slice(0, 8)}`
: old?.label ?? identity.email ?? `ChatGPT ${registrationId.slice(0, 8)}`,
clientId, identity: { issuer: identity.issuer, subject: identity.subject, ...(identity.email ? { email: identity.email } : {}) },
owner: host, session, planEnabled: !!session.accessToken &&
session.scopes.includes('chatgpt.tokens.use.direct') && (firstAuthorization || old.planEnabled),
planAcknowledged: !!old?.planAcknowledged, backgroundConsent: old?.backgroundConsent ?? false,
...(old?.acceptedHandoff ? { acceptedHandoff: old.acceptedHandoff } : {}),
handoff: { state: 'owned' } };
}, () => {
if (signal?.aborted) throw new Error('SIWC sign-in attempt was cancelled.');
}, true);
});
}
export async function setPlanEnabled(registration, { enabled, expectedGeneration }, deps = {}) {
if (typeof enabled !== 'boolean') throw new TypeError('Invalid SIWC plan setting.');
return mutate(registration, deps, old => {
if (!old || old.generation !== expectedGeneration) throw new Error('SIWC registration changed.');
if (enabled && old.session?.refreshUncertain)
throw Object.assign(new Error('ChatGPT refresh outcome is uncertain. Sign in again.'), { recovery: 'reauthorize' });
if (old.handoff.state !== 'owned' || (enabled && (!old.session?.accessToken || !old.session.scopes.includes('chatgpt.tokens.use.direct')))) throw new Error('ChatGPT plan permission is not available.');
return { ...old, planEnabled: enabled,
...(!enabled && old.session?.verificationPending
? { session: { ...old.session, resumePlanEnabled: false } } : {}) };
});
}
export async function acknowledgePlanUse(registration, { expectedGeneration } = {}, deps = {}) {
return mutate(registration, deps, old => {
if (!old || old.generation !== expectedGeneration || !old.planEnabled || old.handoff.state !== 'owned') throw new Error('ChatGPT plan use is unavailable.');
return { ...old, planAcknowledged: true };
});
}
function networkSignal(cfg, timeoutMs) {
cfg.checkLock?.();
return cfg.signal ? AbortSignal.any([cfg.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
}
export async function getSiwcDiscovery(deps = {}) {
const cfg = settings(deps);
const response = await cfg.fetchImpl(`${ISSUER}/.well-known/openid-configuration`, { signal: networkSignal(cfg, 5000) });
if (!response.ok) throw new Error('OpenAI discovery is unavailable.');
const metadata = await readSiwcJson(response);
if (metadata?.issuer !== ISSUER || metadata.authorization_endpoint !== `${ISSUER}/api/accounts/authorize` ||
metadata.token_endpoint !== `${ISSUER}/api/accounts/oauth/token` || metadata.jwks_uri !== `${ISSUER}/.well-known/jwks.json` ||
(metadata.revocation_endpoint !== undefined && (typeof metadata.revocation_endpoint !== 'string' ||
!metadata.revocation_endpoint.startsWith(`${ISSUER}/`) || new URL(metadata.revocation_endpoint).origin !== ISSUER))) throw new Error('OpenAI discovery does not match the supported issuer.');
return metadata;
}
export async function verifySiwcIdToken(idToken, { clientId, nonce, subject, metadata }, deps = {}) {
validateSiwcClientId(clientId);
if (typeof idToken !== 'string' || idToken.length < 20 || idToken.length > 16384 ||
(nonce !== undefined && (typeof nonce !== 'string' || nonce.length < 32)) ||
(subject !== undefined && (typeof subject !== 'string' || !subject))) throw new Error('Invalid SIWC ID token.');
const cfg = settings(deps);
const discovery = metadata ?? await getSiwcDiscovery(deps);
if (discovery.issuer !== ISSUER || discovery.jwks_uri !== `${ISSUER}/.well-known/jwks.json`) throw new Error('Invalid OpenAI verification metadata.');
let response, jwks;
try {
response = await cfg.fetchImpl(discovery.jwks_uri, { signal: networkSignal(cfg, 5000) });
if (!response.ok) throw new Error('OpenAI signing keys are unavailable.');
jwks = await readSiwcJson(response);
} catch {
throw Object.assign(new Error('OpenAI signing keys are temporarily unavailable.'), { verificationUnavailable: true });
}
const { payload } = await jwtVerify(idToken, createLocalJWKSet(jwks), {
issuer: ISSUER, audience: clientId, clockTolerance: 5,
requiredClaims: ['sub', 'exp', 'iat', ...(nonce === undefined ? [] : ['nonce'])],
algorithms: ['RS256', 'ES256'],
});
// OpenAI issues aud as a one-element array. OIDC Core 3.1.3.7: aud must
// contain the client; with several audiences, azp must name the client.
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!audiences.includes(clientId) || audiences.length > 1 && payload.azp !== clientId ||
typeof payload.sub !== 'string' || !payload.sub ||
payload.sub.length > 512 || !Number.isInteger(payload.iat) ||
payload.iat > Math.floor(cfg.now() / 1000) + 5 ||
nonce !== undefined && payload.nonce !== nonce ||
subject !== undefined && payload.sub !== subject ||
payload.email !== undefined && (typeof payload.email !== 'string' || payload.email.length > 256)) throw new Error('SIWC ID token claims do not match.');
return payload;
}
async function tokenRequest(form, cfg, metadata) {
let response;
cfg.checkLock?.();
try { response = await cfg.fetchImpl(metadata.token_endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form), signal: networkSignal(cfg, 10000) }); }
catch { throw Object.assign(new Error('ChatGPT refresh outcome is uncertain. Sign in again before plan use.'), { recovery: 'reauthorize', rotationUncertain: true }); }
if (!response.ok) {
let code;
try { code = (await readSiwcJson(response))?.error; } catch { /* Keep token response private. */ }
if (typeof code !== 'string' || !/^[A-Za-z0-9_:-]{1,128}$/u.test(code)) code = undefined;
const terminal = TERMINAL.has(code);
throw Object.assign(new Error(terminal ? 'ChatGPT sign-in must be renewed.' : 'OpenAI token service rejected the request.'),
{ recovery: terminal ? 'reauthorize' : code === 'invalid_client' ? 'fix-configuration' : 'retry-later',
terminal, definiteNonRotating: response.status === 503 && code === 'temporarily_unavailable',
...(code ? { code } : {}), status: response.status,
...(response.headers?.get('x-request-id')?.match(/^[A-Za-z0-9_-]{1,128}$/u)
? { requestId: response.headers.get('x-request-id') } : {}) });
}
try { return { tokens: await readSiwcJson(response), metadata }; }
catch { throw Object.assign(new Error('ChatGPT session rotation could not be verified.'), { recovery: 'reauthorize', rotationUncertain: true }); }
}
async function blockCandidate(record, path, cfg) {
await atomicFile(path, { ...record, generation: randomUUID(),
session: { ...record.session, verificationPending: false,
pendingIdToken: false, resumePlanEnabled: false } }, cfg);
}
async function completeCandidate(record, path, cfg, minValidityMs, signal, knownMetadata) {
const pending = record.session;
if (signal?.aborted) throw signal.reason;
if (pending.pendingIdToken) {
let metadata = knownMetadata;
if (!metadata) {
try { metadata = await getSiwcDiscovery(cfg); }
catch { throw Object.assign(new Error('OpenAI verification is temporarily unavailable.'), { recovery: 'retry-later' }); }
}
try {
await verifySiwcIdToken(pending.idToken,
{ clientId: record.clientId, subject: record.identity.subject, metadata }, cfg);
} catch (error) {
if (error?.verificationUnavailable)
throw Object.assign(new Error('OpenAI verification is temporarily unavailable.'), { recovery: 'retry-later' });
await blockCandidate(record, path, cfg);
throw Object.assign(new Error('ChatGPT identity changed during refresh.'), { recovery: 'reauthorize' });
}
}
if (pending.expiresAt - cfg.now() <= minValidityMs) {
await blockCandidate(record, path, cfg);
throw Object.assign(new Error('ChatGPT refreshed access expires too soon. Sign in again.'), { recovery: 'reauthorize' });
}
const resumePlanEnabled = pending.resumePlanEnabled;
const verifiedSession = { ...pending };
delete verifiedSession.refreshUncertain;
delete verifiedSession.verificationPending;
delete verifiedSession.pendingIdToken;
delete verifiedSession.resumePlanEnabled;
const next = { ...record, generation: randomUUID(), session: verifiedSession,
planEnabled: resumePlanEnabled && verifiedSession.scopes.includes('chatgpt.tokens.use.direct') };
await atomicFile(path, next, cfg);
if (signal?.aborted) throw signal.reason;
if (!next.planEnabled) throw Object.assign(new Error('ChatGPT plan permission was removed.'), { recovery: 'enable-plan' });
if (verifiedSession.expiresAt - cfg.now() <= minValidityMs) {
await atomicFile(path, { ...next, generation: randomUUID(),
session: { ...verifiedSession, refreshUncertain: true }, planEnabled: false }, cfg);
throw Object.assign(new Error('ChatGPT refreshed access expires too soon.'), { recovery: 'reauthorize' });
}
return { accessToken: next.session.accessToken, expiresAt: next.session.expiresAt, generation: next.generation };
}
export async function getAccessToken({ storageRoot, registrationId }, { runtimeId, minValidityMs = 60000, signal } = {}, deps = {}) {
validateSiwcRuntimeId(runtimeId);
if (!Number.isSafeInteger(minValidityMs) || minValidityMs < 0 || minValidityMs > 3600000) throw new TypeError('Invalid SIWC token validity.');
const cfg = settings({ ...deps, signal: signal && deps.signal ? AbortSignal.any([signal, deps.signal]) : signal ?? deps.signal }), root = rootOf(storageRoot);
const host = await readSiwcHost({ storageRoot: root, runtimeId }, deps);
return locked(root, registrationId, cfg, async cfg => {
const path = fileOf(root, registrationId);
const record = validateRecord(await readFile(path, cfg), registrationId);
if (record.owner.hostId !== host.hostId || record.owner.runtimeId !== runtimeId || record.handoff.state !== 'owned')
throw Object.assign(new Error('SIWC account belongs to another runtime.'), { recovery: 'resolve-handoff' });
cfg.callerSignal?.throwIfAborted();
const protectedCfg = { ...cfg, signal: cfg.holderSignal, callerSignal: null };
if (record.session?.verificationPending)
return completeCandidate(record, path, protectedCfg, minValidityMs, protectedCfg.signal);
if (record.session?.reauthorize || record.session?.refreshUncertain ||
!record.session?.accessToken && !record.session?.identityOnly)
throw Object.assign(new Error('ChatGPT sign-in must be renewed.'), { recovery: 'reauthorize' });
if (!record.planEnabled || !record.session?.scopes?.includes('chatgpt.tokens.use.direct'))
throw Object.assign(new Error('ChatGPT plan permission is not enabled.'), { recovery: 'enable-plan' });
cfg.callerSignal?.throwIfAborted();
const session = record.session;
if (session.accessToken && session.expiresAt - cfg.now() > minValidityMs) return { accessToken: session.accessToken, expiresAt: session.expiresAt, generation: record.generation };
if (!session.refreshToken) throw Object.assign(new Error('ChatGPT sign-in must be renewed.'), { recovery: 'reauthorize' });
let metadata;
try { metadata = await getSiwcDiscovery(cfg); }
catch { throw Object.assign(new Error('OpenAI discovery is temporarily unavailable.'), { recovery: 'retry-later' }); }
cfg.callerSignal?.throwIfAborted();
// Persist the freeze before any refresh POST. A later write failure must not
// allow another process to replay a refresh token whose rotation is unknown.
const frozen = { ...record, generation: randomUUID(),
session: { ...session, refreshUncertain: true }, planEnabled: false };
await atomicFile(path, frozen, protectedCfg, () => cfg.callerSignal?.throwIfAborted());
if (cfg.callerSignal?.aborted) {
await atomicFile(path, { ...record, generation: randomUUID() }, protectedCfg);
throw cfg.callerSignal.reason;
}
let rotation;
try {
rotation = await tokenRequest({ grant_type: 'refresh_token', client_id: record.clientId,
refresh_token: session.refreshToken, resource: RESOURCE }, protectedCfg, metadata);
} catch (error) {
if (error.definiteNonRotating)
await atomicFile(path, { ...record, generation: randomUUID() }, protectedCfg);
else if (error.terminal) await atomicFile(path, { ...frozen, generation: randomUUID(),
session: { reauthorize: true } }, protectedCfg);
throw error;
}
let nextSession;
try { nextSession = normalizeTokens(rotation.tokens, cfg.now(), session); }
catch { throw Object.assign(new Error('ChatGPT session rotation could not be verified.'), { recovery: 'reauthorize' }); }
// A mismatched returned client can never become a retryable candidate.
// Persist R1 for revocation, but keep its owner permanently blocked.
const clientMismatch = rotation.tokens.client_id !== undefined &&
rotation.tokens.client_id !== record.clientId;
const candidate = { ...frozen, generation: randomUUID(),
session: clientMismatch ? { ...nextSession, refreshUncertain: true }
: { ...nextSession, refreshUncertain: true, verificationPending: true,
pendingIdToken: !!rotation.tokens.id_token, resumePlanEnabled: record.planEnabled } };
await atomicFile(path, candidate, protectedCfg);
if (clientMismatch)
throw Object.assign(new Error('SIWC rotating client changed.'), { recovery: 'reauthorize' });
return completeCandidate(candidate, path, protectedCfg, minValidityMs, protectedCfg.signal, metadata);
});
}
function validateThreadBinding(binding, record) {
if (!binding || typeof binding !== 'object' || Object.keys(binding).sort().join(',') !== 'identity,model,threadId' ||
!binding.identity || typeof binding.identity !== 'object' ||
Object.keys(binding.identity).sort().join(',') !== 'clientId,issuer,subject' ||
typeof binding.threadId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(binding.threadId) ||
typeof binding.model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(binding.model) ||
binding.identity.issuer !== record.identity.issuer ||
binding.identity.clientId !== record.clientId ||
binding.identity.subject !== record.identity.subject) throw new Error('Invalid SIWC thread binding.');
return binding;
}
async function ownedThreadRecord(root, registrationId, runtimeId, cfg, deps) {
const record = validateRecord(await readFile(fileOf(root, registrationId), cfg), registrationId);
const host = await readSiwcHost({ storageRoot: root, runtimeId }, deps);
if (record.owner.hostId !== host.hostId || record.owner.runtimeId !== runtimeId ||
record.handoff.state !== 'owned' || !record.planEnabled || !record.session?.accessToken)
throw new Error('SIWC thread session is not owned or enabled here.');
return record;
}
async function threadStorage(root, registrationId, record, cfg) {
await directory(join(root, 'threads'), cfg);
const path = join(root, 'threads', `${validateSiwcRegistrationId(registrationId)}.json`);
const saved = await readFile(path, cfg);
if (!saved) return { path, bindings: [] };
if (saved.schemaVersion !== 1 || saved.registrationId !== registrationId ||
saved.owner?.hostId !== record.owner.hostId || saved.owner.runtimeId !== record.owner.runtimeId ||
!Array.isArray(saved.bindings) || saved.bindings.length > 1024) throw new Error('Invalid SIWC thread storage.');
const ids = new Set();
for (const binding of saved.bindings) {
validateThreadBinding(binding, record);
if (ids.has(binding.threadId)) throw new Error('Duplicate SIWC thread binding.');
ids.add(binding.threadId);
}
return { path, bindings: saved.bindings };
}
export async function listSiwcThreadBindings({ storageRoot, registrationId }, { runtimeId } = {}, deps = {}) {
validateSiwcRuntimeId(runtimeId);
const cfg = settings(deps), root = rootOf(storageRoot);
return locked(root, registrationId, cfg, async cfg => {
const record = await ownedThreadRecord(root, registrationId, runtimeId, cfg, deps);
return (await threadStorage(root, registrationId, record, cfg)).bindings;
});
}
export async function recordSiwcThreadBinding({ storageRoot, registrationId },
{ runtimeId, threadId, model, identity } = {}, deps = {}) {
validateSiwcRuntimeId(runtimeId);
const cfg = settings(deps), root = rootOf(storageRoot);
return locked(root, registrationId, cfg, async cfg => {
const record = await ownedThreadRecord(root, registrationId, runtimeId, cfg, deps);
validateThreadBinding({ threadId, model, identity }, record);
const { path, bindings } = await threadStorage(root, registrationId, record, cfg);
const existing = bindings.find(item => item.threadId === threadId);
if (existing) {
if (existing.model !== model) throw new Error('SIWC thread belongs to another model.');
return existing;
}
if (bindings.length === 1024) throw new Error('SIWC thread binding limit reached.');
await atomicFile(path, { schemaVersion: 1, registrationId, owner: record.owner,
bindings: [...bindings, { threadId, model,
identity: { issuer: record.identity.issuer, clientId: record.clientId,
subject: record.identity.subject } }] }, cfg);
return { threadId, model, identity: { issuer: record.identity.issuer,
clientId: record.clientId, subject: record.identity.subject } };
});
}
export async function signOut(registration, { runtimeId }, deps = {}) {
validateSiwcRuntimeId(runtimeId);
const cfg = settings(deps), root = rootOf(registration.storageRoot);
let revocation = 'not-applicable';
const host = await readSiwcHost({ storageRoot: root, runtimeId }, deps);
const accountView = await locked(root, registration.registrationId, cfg, async cfg => {
const path = fileOf(root, registration.registrationId);
const record = validateRecord(await readFile(path, cfg), registration.registrationId);
if (record.owner.hostId !== host.hostId || record.owner.runtimeId !== runtimeId || record.handoff.state !== 'owned') throw new Error('SIWC session belongs to another runtime.');
const refreshToken = record.session?.refreshToken;
if (refreshToken) {
revocation = 'unconfirmed';
try {
const metadata = await getSiwcDiscovery(cfg);
if (typeof metadata.revocation_endpoint === 'string') {
for (let attempt = 0; attempt < 3; attempt++) {
try {
cfg.checkLock?.();
const response = await cfg.fetchImpl(metadata.revocation_endpoint, {
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token: refreshToken, token_type_hint: 'refresh_token', client_id: record.clientId }),
signal: networkSignal(cfg, 5000),
});
if (response.status === 200) {
if (!record.session.refreshUncertain) revocation = 'confirmed';
break;
}
if (response.status < 500) break;
} catch { /* Bounded retry of uncertain network result. */ }
if (attempt < 2) await pause(100 * (attempt + 1));
}
}
} catch { /* Local sign-out remains mandatory even when revocation is uncertain. */ }
}
const next = { ...record, generation: randomUUID(), session: {}, planEnabled: false, backgroundConsent: false };
await atomicFile(path, next, cfg);
return account(next);
});
return { account: accountView, revocation };
}
function proof(secret, handoffId, binding) {
return createHmac('sha256', Buffer.from(secret, 'base64url')).update(JSON.stringify({ handoffId, binding })).digest('base64url');
}
export async function prepareAuthHandoff(registration, { expectedGeneration, target, backgroundConsent }, deps = {}) {
const destination = assertTarget(target);
assertGeneration(expectedGeneration);
if (!validConsent(backgroundConsent)) throw new TypeError('Invalid SIWC background consent.');
let result;
await mutate(registration, deps, old => {
if (!old || old.generation !== expectedGeneration || old.handoff.state !== 'owned' || !old.session?.refreshToken ||
!old.planEnabled || !old.session.scopes.includes('chatgpt.tokens.use.direct') ||
old.owner.hostId === destination.hostId) throw new Error('SIWC session cannot be transferred.');
const handoffId = randomUUID();
const binding = { registrationId: old.registrationId, clientId: old.clientId, generation: old.generation,
source: old.owner, target: destination, backgroundConsent };
result = { handoffId, binding };
return { ...old, handoff: { state: 'handoff-pending', handoffId, binding,
proofSecret: randomBytes(32).toString('base64url'), exported: false }, backgroundConsent };
});
return result;
}
export async function readAuthHandoff(registration, { handoffId, expectedGeneration }, deps = {}) {
assertGeneration(handoffId);
assertGeneration(expectedGeneration);
const cfg = settings(deps), root = rootOf(registration.storageRoot);
return locked(root, registration.registrationId, cfg, async cfg => {
const path = fileOf(root, registration.registrationId);
const record = validateRecord(await readFile(path, cfg), registration.registrationId);
const host = await readSiwcHost({ storageRoot: root, runtimeId: record.owner.runtimeId }, deps);
if (record.owner.hostId !== host.hostId || record.handoff.state !== 'handoff-pending' ||
record.handoff.exported || record.handoff.handoffId !== handoffId ||
record.handoff.binding.generation !== expectedGeneration) throw new Error('SIWC handoff changed; transfer remains frozen.');
const exported = { ...record, generation: randomUUID(), handoff: { ...record.handoff, exported: true } };
const contents = Buffer.from(JSON.stringify({ schemaVersion: 1, handoffId, binding: exported.handoff.binding,
proofSecret: exported.handoff.proofSecret, record: { ...exported, handoff: { state: 'owned' } } }));
if (contents.length > MAX_FILE) throw new Error('SIWC handoff is too large.');
await atomicFile(path, exported, cfg);
return contents;
});
}
async function readExistingRegistration(root, registrationId, cfg) {
validateSiwcRegistrationId(registrationId);
try {
await assertSafePath(root, 'directory', cfg);
await assertSafePath(join(root, 'registrations'), 'directory', cfg);
} catch (error) {
if (error?.code === 'ENOENT') return null;
throw error;
}
const record = await readFile(fileOf(root, registrationId), cfg);
if (record?.kind === 'pending') {
validatePending(record, registrationId);
return null;
}
return record ? validateRecord(record, registrationId) : null;
}
export async function readPendingAuthHandoff({ storageRoot, registrationId }, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
const record = await readExistingRegistration(root, registrationId, cfg);
if (record?.handoff.state !== 'handoff-pending') return null;
const host = await readSiwcHost({ storageRoot: root, runtimeId: record.owner.runtimeId }, deps);
if (host.hostId !== record.owner.hostId) throw new Error('SIWC registration belongs to another host.');
return { handoffId: record.handoff.handoffId, binding: record.handoff.binding,
target: record.handoff.binding.target,
identity: { issuer: record.identity.issuer, clientId: record.clientId, subject: record.identity.subject } };
}
export async function readAuthHandoffReceipt({ storageRoot, registrationId },
{ runtimeId, handoffId, expectedTarget, expectedBinding, identity } = {}, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
validateSiwcRegistrationId(registrationId);
validateSiwcRuntimeId(runtimeId);
assertGeneration(handoffId);
const target = assertTarget(expectedTarget);
const binding = validateHandoffBinding(expectedBinding);
if (!identity || typeof identity !== 'object' ||
Object.keys(identity).sort().join(',') !== 'clientId,issuer,subject' ||
identity.issuer !== ISSUER || typeof identity.subject !== 'string' ||
!identity.subject || identity.subject.length > 512 ||
identity.clientId !== binding.clientId || binding.registrationId !== registrationId ||
target.runtimeId !== runtimeId || binding.target.hostId !== target.hostId ||
binding.target.runtimeId !== runtimeId) throw new Error('SIWC handoff receipt selection does not match.');
const record = await readExistingRegistration(root, registrationId, cfg);
if (!record) return null;
const host = await readSiwcHost({ storageRoot: root, runtimeId }, deps);
const accepted = record.acceptedHandoff;
if (host.hostId !== target.hostId || record.owner.hostId !== target.hostId ||
record.owner.runtimeId !== runtimeId || record.identity.issuer !== identity.issuer ||
record.identity.subject !== identity.subject || record.clientId !== identity.clientId ||
accepted?.handoffId !== handoffId ||
JSON.stringify(accepted.binding) !== JSON.stringify(binding))
throw new Error('SIWC handoff receipt does not match.');
return accepted;
}
export async function acceptAuthHandoff({ storageRoot, runtimeId, expectedTarget, expectedRegistrationId, contents }, deps = {}) {
const cfg = settings(deps), root = rootOf(storageRoot);
validateSiwcRuntimeId(runtimeId);
validateSiwcRegistrationId(expectedRegistrationId);
const target = assertTarget(expectedTarget);
if (!Buffer.isBuffer(contents) || contents.length > MAX_FILE || !contents.length) throw new Error('Invalid protected SIWC handoff.');
let transfer;
try { transfer = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(contents)); }
catch { throw new Error('Invalid protected SIWC handoff.'); }
const binding = validateHandoffBinding(transfer?.binding);
// Reject before lock setup can create storage directories; recheck under the acceptance lock.
if (binding.registrationId !== expectedRegistrationId) throw new Error('SIWC handoff registration does not match.');
assertGeneration(transfer.handoffId);
const record = validateRecord(transfer.record, binding.registrationId);
if (transfer.schemaVersion !== 1 ||
target.runtimeId !== runtimeId || binding.target?.hostId !== target.hostId || binding.target.runtimeId !== runtimeId ||
binding.source?.hostId === target.hostId ||
binding.registrationId !== record.registrationId || binding.clientId !== record.clientId || binding.generation === record.generation ||
binding.source.hostId !== record.owner.hostId || binding.source.runtimeId !== record.owner.runtimeId ||
record.handoff.state !== 'owned' ||
typeof transfer.proofSecret !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(transfer.proofSecret) ||
!record.session?.refreshToken) throw new Error('SIWC handoff target or account does not match.');
return locked(root, 'identitymap', cfg, async cfg => {
if (binding.registrationId !== expectedRegistrationId) throw new Error('SIWC handoff registration does not match.');
const host = await readSiwcHost({ storageRoot: root, runtimeId }, deps);
if (host.hostId !== target.hostId) throw new Error('SIWC destination host changed.');
const receipt = { handoffId: transfer.handoffId, binding, proof: proof(transfer.proofSecret, transfer.handoffId, binding) };
const acceptedHandoff = { handoffId: transfer.handoffId, binding, receipt };
await rejectDuplicateClient(root, record.registrationId, record.clientId, cfg);
await locked(root, record.registrationId, cfg, async cfg => {
const path = fileOf(root, record.registrationId);
if (await readFile(path, cfg)) throw new Error('Destination already has this SIWC registration.');
const next = { ...record, owner: host, generation: randomUUID(), backgroundConsent: binding.backgroundConsent,
handoff: { state: 'owned' }, acceptedHandoff };
validateRecord(next, record.registrationId);
await atomicFile(path, next, cfg);
});
return acceptedHandoff;
});
}
export async function finishAuthHandoff(registration, { handoffId, receipt }, deps = {}) {
assertGeneration(handoffId);
validateHandoffReceipt(receipt);
const cfg = settings(deps), root = rootOf(registration.storageRoot);
return locked(root, registration.registrationId, cfg, async cfg => {
const path = fileOf(root, registration.registrationId);
const old = validateRecord(await readFile(path, cfg), registration.registrationId);
const host = await readSiwcHost({ storageRoot: root, runtimeId: old.owner.runtimeId }, deps);
if (old.owner.hostId !== host.hostId) throw new Error('SIWC registration belongs to another host.');
const handoff = old.handoff;
if (handoff.state === 'transferred' && handoff.handoffId === handoffId &&
JSON.stringify(handoff.receipt) === JSON.stringify(receipt)) return account(old);
if (!handoff || handoff.state !== 'handoff-pending' || !handoff.exported || handoff.handoffId !== handoffId ||
receipt.handoffId !== handoffId || JSON.stringify(receipt.binding) !== JSON.stringify(handoff.binding))
throw new Error('SIWC handoff receipt does not match.');
const expected = Buffer.from(proof(handoff.proofSecret, handoffId, handoff.binding));
const supplied = Buffer.from(receipt.proof);
if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) throw new Error('SIWC handoff receipt does not match.');
const next = { ...old, generation: randomUUID(), session: {}, planEnabled: false,
handoff: { state: 'transferred', handoffId, receipt } };
validateRecord(next, registration.registrationId);
await atomicFile(path, next, cfg);
return account(next);
});
}