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); }); }