@modelprofile.com/authswitch 8.0.0 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +56 -0
- package/dist_ts/authority-contract.js +54 -2
- package/dist_ts/authority-import-contract.d.ts +211 -0
- package/dist_ts/authority-import-contract.js +17 -0
- package/dist_ts/classes.authoritybroker.js +3 -2
- package/dist_ts/classes.authoritycli.d.ts +4 -0
- package/dist_ts/classes.authoritycli.js +148 -11
- package/dist_ts/classes.authorityclient.d.ts +24 -11
- package/dist_ts/classes.authorityclient.js +40 -22
- package/dist_ts/classes.authoritydaemon.d.ts +12 -0
- package/dist_ts/classes.authoritydaemon.js +123 -36
- package/dist_ts/classes.authoritydatabase.d.ts +14 -1
- package/dist_ts/classes.authoritydatabase.js +48 -16
- package/dist_ts/classes.authorityimport.d.ts +176 -0
- package/dist_ts/classes.authorityimport.js +664 -0
- package/dist_ts/classes.authoritymodels.d.ts +16 -1
- package/dist_ts/classes.authoritymodels.js +31 -3
- package/dist_ts/classes.authorityservice.d.ts +10 -0
- package/dist_ts/classes.authorityservice.js +24 -1
- package/dist_ts/classes.claudeauthority.js +19 -11
- package/dist_ts/classes.claudenative.d.ts +63 -3
- package/dist_ts/classes.claudenative.js +68 -8
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +6 -3
- package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
- package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/dist_ts/ts_migration/legacysources/authswitchstores.d.ts +2 -0
- package/dist_ts/ts_migration/legacysources/authswitchstores.js +244 -0
- package/dist_ts/ts_migration/legacysources/index.d.ts +18 -0
- package/dist_ts/ts_migration/legacysources/index.js +24 -0
- package/dist_ts/ts_migration/legacysources/material.d.ts +37 -0
- package/dist_ts/ts_migration/legacysources/material.js +120 -0
- package/dist_ts/ts_migration/legacysources/nativestores.d.ts +2 -0
- package/dist_ts/ts_migration/legacysources/nativestores.js +214 -0
- package/dist_ts/ts_migration/legacysources/shared.d.ts +125 -0
- package/dist_ts/ts_migration/legacysources/shared.js +140 -0
- package/package.json +9 -1
- package/readme.md +111 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +106 -0
- package/ts/authority-import-contract.ts +217 -0
- package/ts/classes.authoritybroker.ts +2 -1
- package/ts/classes.authoritycli.ts +164 -10
- package/ts/classes.authorityclient.ts +65 -21
- package/ts/classes.authoritydaemon.ts +121 -34
- package/ts/classes.authoritydatabase.ts +49 -15
- package/ts/classes.authorityimport.ts +746 -0
- package/ts/classes.authoritymodels.ts +32 -2
- package/ts/classes.authorityservice.ts +27 -0
- package/ts/classes.claudeauthority.ts +22 -10
- package/ts/classes.claudenative.ts +104 -10
- package/ts/index.ts +5 -2
- package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
- package/ts/ts_migration/legacysources/authswitchstores.ts +211 -0
- package/ts/ts_migration/legacysources/index.ts +35 -0
- package/ts/ts_migration/legacysources/material.ts +134 -0
- package/ts/ts_migration/legacysources/nativestores.ts +212 -0
- package/ts/ts_migration/legacysources/shared.ts +218 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
3
|
+
import { authSwitchEnvironmentId } from './classes.operations.js';
|
|
4
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
5
|
+
import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
6
|
+
import { ClaudeAccountStatus, type IClaudeLogin, type IClaudeStatusRead } from './classes.claudestatus.js';
|
|
7
|
+
import { ClaudeTokenRefresh, type TClaudeAuthorityRefreshResult } from './classes.claudetokenrefresh.js';
|
|
8
|
+
import { CodexDaemon } from './classes.codexdaemon.js';
|
|
9
|
+
import { CodexHome } from './classes.codexhome.js';
|
|
10
|
+
import { HarnessProcesses, type THarnessBinary } from './classes.harnessprocesses.js';
|
|
11
|
+
import { LegacySourceChangedError, readLegacySourceEnrollments, readLegacySourceMaterial,
|
|
12
|
+
readLegacySources, resolveLegacySourceLocations } from './ts_migration/legacysources/index.js';
|
|
13
|
+
import type { ILegacySourceRecord, TLegacySourceMaterial } from './ts_migration/legacysources/index.js';
|
|
14
|
+
import type { IStoredAuthorityAccount, IStoredAuthorityGrant, IStoredAuthorityHandoff,
|
|
15
|
+
IStoredAuthorityMigrationLedger, TStoredGrantPurpose } from './classes.authoritymodels.js';
|
|
16
|
+
import type { IAuthSwitchImportInventory, IAuthSwitchImportResult, IAuthSwitchImportSource,
|
|
17
|
+
IAuthSwitchImportStatus, IAuthSwitchImportStatusEntry, TAuthSwitchImportAction,
|
|
18
|
+
TAuthSwitchImportLedgerStatus, TAuthSwitchImportSubmission } from './authority-import-contract.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What the owner must know before importing anything.
|
|
22
|
+
*
|
|
23
|
+
* The run-order line is not advice: after an account is imported, its legacy record holds a refresh token
|
|
24
|
+
* the provider has already rotated away, and the everyday legacy commands would restore or exercise exactly
|
|
25
|
+
* that record. The import is therefore only safe once those commands run on the authority.
|
|
26
|
+
*/
|
|
27
|
+
const INVENTORY_NOTICE: readonly string[] = [
|
|
28
|
+
'This inventory only reads. It changes no authority record, no legacy store and no credential.',
|
|
29
|
+
'Importing transfers ownership; it does not copy. Verifying a saved authswitch record refreshes its token, '
|
|
30
|
+
+ 'and the legacy copy is unusable from that moment. The fallback is one device sign-in for that account.',
|
|
31
|
+
'A native Codex, Claude or OpenCode store is projected, not imported: the native tool stays the only '
|
|
32
|
+
+ 'refresher, and its record is verified by a provider read that rotates nothing.',
|
|
33
|
+
'Do not import until the everyday commands run on the authority and the native projections exist. Until '
|
|
34
|
+
+ 'then a legacy command could restore or exercise a record whose token an import has already replaced.',
|
|
35
|
+
'No file under the legacy stores is deleted by an import. Retiring those files is a separate later step.',
|
|
36
|
+
'The authority store is deliberately not backed up. Losing it later loses the remote-control enrollments '
|
|
37
|
+
+ 'and every runtime binding as well as the logins: each account then needs a device sign-in, and each '
|
|
38
|
+
+ 'enrollment has to be paired again.',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What this daemon can observe about writers of the legacy stores.
|
|
43
|
+
*
|
|
44
|
+
* It cannot observe every writer: a host that uses the legacy library inside its own process is invisible
|
|
45
|
+
* here, which is why a submission also carries the caller's own assertion. Unknown is never idle.
|
|
46
|
+
*/
|
|
47
|
+
export interface IAuthSwitchImportQuiescence {
|
|
48
|
+
/** The live pid of the authswitch watch that owns the legacy stores, or null when none runs. */
|
|
49
|
+
watchHolderPid(): number | null;
|
|
50
|
+
runningHarnessPids(binary: THarnessBinary): readonly number[];
|
|
51
|
+
/** Codex's own answer about its app-server. `null` means unknown, which counts as running. */
|
|
52
|
+
codexAppServerRunning(): boolean | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Exactly the two provider operations an import needs, so nothing here can reach for a third.
|
|
57
|
+
*
|
|
58
|
+
* `OpenAiProviderAdapter` satisfies this; naming only what is used keeps the rotating call countable and
|
|
59
|
+
* makes it visible that a projection never touches `refreshCredential`.
|
|
60
|
+
*/
|
|
61
|
+
export interface IAuthSwitchImportOpenAiOperations {
|
|
62
|
+
refreshCredential(credential: plugins.flexAccounts.IOpenAiChatGptOAuthCredential):
|
|
63
|
+
Promise<plugins.flexAccounts.TSmartAiProviderRefreshResult>;
|
|
64
|
+
getAccountRateLimits(credential: plugins.flexAccounts.TOpenAiChatGptReadCredential):
|
|
65
|
+
Promise<plugins.flexAccounts.TSmartAiProviderAccountRateLimitsResult>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The non-rotating Claude proof: one profile read that must name the expected immutable identity. */
|
|
69
|
+
export interface IAuthSwitchImportClaudeStatus {
|
|
70
|
+
readDetailed(login: IClaudeLogin): Promise<IClaudeStatusRead>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The rotating Claude refresh, with its own explicit uncertain outcome. */
|
|
74
|
+
export interface IAuthSwitchImportClaudeTokens {
|
|
75
|
+
refreshForAuthority(oauth: Record<string, unknown>): Promise<TClaudeAuthorityRefreshResult>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface IAuthSwitchAuthorityImportOptions {
|
|
79
|
+
/** Locates the legacy stores, exactly as each owning class locates its own. */
|
|
80
|
+
env?: NodeJS.ProcessEnv;
|
|
81
|
+
homeDirectory?: string;
|
|
82
|
+
now?: () => number;
|
|
83
|
+
/** The one rotating refresh of an imported ChatGPT login, and the non-rotating read that proves one. */
|
|
84
|
+
openAi?: IAuthSwitchImportOpenAiOperations;
|
|
85
|
+
claudeStatus?: IAuthSwitchImportClaudeStatus;
|
|
86
|
+
claudeTokens?: IAuthSwitchImportClaudeTokens;
|
|
87
|
+
secrets?: IAuthSwitchSecretCodec;
|
|
88
|
+
quiescence?: IAuthSwitchImportQuiescence;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Reads the host: the watch lock file, the process table and Codex's own daemon status. */
|
|
92
|
+
export class AuthSwitchImportHostQuiescence implements IAuthSwitchImportQuiescence {
|
|
93
|
+
constructor(private readonly authSwitchHome: string, private readonly codexHome: string) {}
|
|
94
|
+
|
|
95
|
+
public watchHolderPid(): number | null {
|
|
96
|
+
// The lock is read, never acquired: an inventory or an import must not become the watch.
|
|
97
|
+
let raw: string;
|
|
98
|
+
try { raw = plugins.fs.readFileSync(plugins.path.join(this.authSwitchHome, 'watch.lock'), 'utf8'); }
|
|
99
|
+
catch { return null; }
|
|
100
|
+
let pid: unknown;
|
|
101
|
+
try { pid = (JSON.parse(raw) as Record<string, unknown>).pid; } catch { return null; }
|
|
102
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
103
|
+
try { process.kill(pid, 0); return pid; }
|
|
104
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM' ? pid : null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public runningHarnessPids(binary: THarnessBinary): readonly number[] {
|
|
108
|
+
return new HarnessProcesses(binary).list().map(item => item.pid);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public codexAppServerRunning(): boolean | null {
|
|
112
|
+
const status = new CodexDaemon(new CodexHome(this.codexHome)).status();
|
|
113
|
+
return status === null ? null : status.running;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The grant one source becomes: its purpose, its audience and who refreshes it once the import settles. */
|
|
118
|
+
interface IImportGrantPlan {
|
|
119
|
+
providerId: 'openai' | 'anthropic';
|
|
120
|
+
purpose: TStoredGrantPurpose;
|
|
121
|
+
audience: string;
|
|
122
|
+
/** The owner a settled import leaves behind. A projection never becomes `daemon`. */
|
|
123
|
+
settledOwner: IStoredAuthorityGrant['owner'];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const idHash = (...values: string[]): string =>
|
|
127
|
+
plugins.crypto.createHash('sha256').update(JSON.stringify(values)).digest('hex');
|
|
128
|
+
|
|
129
|
+
const grantPlan = (record: Pick<ILegacySourceRecord, 'sourceKind' | 'providerId'>): IImportGrantPlan => {
|
|
130
|
+
if (record.sourceKind === 'claude_native') {
|
|
131
|
+
return { providerId: 'anthropic', purpose: 'claude_host_native', audience: 'claude-code-host',
|
|
132
|
+
settledOwner: 'claude_native' };
|
|
133
|
+
}
|
|
134
|
+
if (record.sourceKind === 'opencode_native') {
|
|
135
|
+
return { providerId: 'openai', purpose: 'opencode_native', audience: 'chatgpt',
|
|
136
|
+
settledOwner: 'legacy_native' };
|
|
137
|
+
}
|
|
138
|
+
if (record.sourceKind === 'codex_native') {
|
|
139
|
+
return { providerId: 'openai', purpose: 'openai_managed', audience: 'chatgpt',
|
|
140
|
+
settledOwner: 'legacy_native' };
|
|
141
|
+
}
|
|
142
|
+
return record.providerId === 'anthropic'
|
|
143
|
+
? { providerId: 'anthropic', purpose: 'claude_host_native', audience: 'claude-code-host',
|
|
144
|
+
settledOwner: 'daemon' }
|
|
145
|
+
: { providerId: 'openai', purpose: 'openai_managed', audience: 'chatgpt', settledOwner: 'daemon' };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** One grant per account, purpose and audience -- the same rule the device login uses. */
|
|
149
|
+
const importGrantId = (accountId: string, plan: IImportGrantPlan): string =>
|
|
150
|
+
idHash('authswitch-grant-v1', accountId, plan.purpose, plan.audience);
|
|
151
|
+
const importHandoffId = (sourceId: string): string => idHash('authswitch-import-handoff-v1', sourceId);
|
|
152
|
+
|
|
153
|
+
/** The handoff phases past the pre-send marker: the exchange may already have consumed the refresh token. */
|
|
154
|
+
const spentHandoffPhases: readonly IStoredAuthorityHandoff['phase'][] =
|
|
155
|
+
['source_stopped', 'needs_reauth', 'aborted'];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What a ledger status asks of the owner.
|
|
159
|
+
*
|
|
160
|
+
* An unsettled row is resumed by submitting the same source again -- unless its handoff got past the marker
|
|
161
|
+
* that says the sign-in exchange may have been sent, because a rotating refresh token is never sent twice.
|
|
162
|
+
* Reading the handoff here is what keeps this answer truthful: without it a source whose only remaining
|
|
163
|
+
* repair is a device sign-in would invite a submission that can only end in a quarantine.
|
|
164
|
+
*/
|
|
165
|
+
const importAction = (status: TAuthSwitchImportLedgerStatus,
|
|
166
|
+
handoff: IStoredAuthorityHandoff | null): TAuthSwitchImportAction => {
|
|
167
|
+
if (status === 'complete') return 'none';
|
|
168
|
+
if (status === 'quarantined') return 'device_login';
|
|
169
|
+
return handoff !== null && spentHandoffPhases.includes(handoff.phase) ? 'device_login' : 'resume';
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A refusal the owner can act on. It never leaves a half-written import behind.
|
|
174
|
+
*
|
|
175
|
+
* One class hierarchy for the whole authority: this is the authority's refusal with the importer's code,
|
|
176
|
+
* which is what keeps the marker this route has published since 8.1.0 on the wire.
|
|
177
|
+
*/
|
|
178
|
+
export class AuthSwitchImportRefusal extends AuthSwitchRefusal {
|
|
179
|
+
constructor(instruction: string) {
|
|
180
|
+
super('import_refusal', instruction);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The annotation on the binding, not only on the arrow, is what lets a refusal narrow the code after it.
|
|
185
|
+
const refuse: (reason: string) => never = reason => { throw new AuthSwitchImportRefusal(reason); };
|
|
186
|
+
|
|
187
|
+
const RUN_ORDER_REFUSAL = 'Importing needs the run-order acknowledgement: an imported source\'s legacy copy '
|
|
188
|
+
+ 'holds a refresh token the provider has rotated away, and the everyday legacy commands would restore or '
|
|
189
|
+
+ 'exercise exactly that copy. Import only once those commands run on the authority and the native '
|
|
190
|
+
+ 'projections exist.';
|
|
191
|
+
|
|
192
|
+
/** Strips the source location: a ledger row stores its hash, and no response may carry the path. */
|
|
193
|
+
const publicSource = (record: ILegacySourceRecord,
|
|
194
|
+
ledgerStatus: IAuthSwitchImportSource['ledgerStatus']): IAuthSwitchImportSource => ({
|
|
195
|
+
id: record.id,
|
|
196
|
+
sourceKind: record.sourceKind,
|
|
197
|
+
sourcePathHash: record.sourcePathHash,
|
|
198
|
+
sourceDigest: record.sourceDigest,
|
|
199
|
+
treatment: record.treatment,
|
|
200
|
+
proof: record.proof,
|
|
201
|
+
accountId: record.accountId,
|
|
202
|
+
providerId: record.providerId,
|
|
203
|
+
email: record.email,
|
|
204
|
+
plan: record.plan,
|
|
205
|
+
label: record.label,
|
|
206
|
+
accessExpiresAt: record.accessExpiresAt,
|
|
207
|
+
enrollmentCount: record.enrollmentCount,
|
|
208
|
+
ledgerStatus,
|
|
209
|
+
problems: [...record.problems],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The one-time account import.
|
|
214
|
+
*
|
|
215
|
+
* It reads the legacy stores through the isolated migration readers and reports them against the authority's
|
|
216
|
+
* migration ledger. Nothing here is a runtime fallback: no ordinary authority operation reaches this class,
|
|
217
|
+
* and it never writes to a legacy store.
|
|
218
|
+
*/
|
|
219
|
+
export class AuthSwitchAuthorityImport {
|
|
220
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
221
|
+
private readonly homeDirectory: string;
|
|
222
|
+
private readonly now: () => number;
|
|
223
|
+
private readonly openAi: IAuthSwitchImportOpenAiOperations;
|
|
224
|
+
private readonly claudeStatus: IAuthSwitchImportClaudeStatus;
|
|
225
|
+
private readonly claudeTokens: IAuthSwitchImportClaudeTokens;
|
|
226
|
+
private readonly secrets: IAuthSwitchSecretCodec;
|
|
227
|
+
private readonly quiescence: IAuthSwitchImportQuiescence;
|
|
228
|
+
|
|
229
|
+
constructor(private readonly database: AuthSwitchAuthorityDatabase,
|
|
230
|
+
options: IAuthSwitchAuthorityImportOptions = {}) {
|
|
231
|
+
this.env = options.env ?? process.env;
|
|
232
|
+
this.homeDirectory = options.homeDirectory ?? plugins.os.homedir();
|
|
233
|
+
this.now = options.now ?? Date.now;
|
|
234
|
+
this.openAi = options.openAi ?? new plugins.flexAccounts.OpenAiProviderAdapter();
|
|
235
|
+
this.claudeStatus = options.claudeStatus ?? new ClaudeAccountStatus();
|
|
236
|
+
this.claudeTokens = options.claudeTokens ?? new ClaudeTokenRefresh();
|
|
237
|
+
this.secrets = options.secrets ?? new AuthSwitchTpmSecretCodec();
|
|
238
|
+
const locations = resolveLegacySourceLocations(this.env, this.homeDirectory);
|
|
239
|
+
this.quiescence = options.quiescence
|
|
240
|
+
?? new AuthSwitchImportHostQuiescence(locations.authSwitchHome, locations.codexHome);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The credential-free report. Reads the legacy stores and the ledger; writes nothing. */
|
|
244
|
+
public async inventory(): Promise<IAuthSwitchImportInventory> {
|
|
245
|
+
const now = this.now();
|
|
246
|
+
const report = readLegacySources({ env: this.env, homeDirectory: this.homeDirectory, now });
|
|
247
|
+
const sources: IAuthSwitchImportSource[] = [];
|
|
248
|
+
for (const record of report.sources) {
|
|
249
|
+
const ledger = await this.database.readMigrationLedger(record.id);
|
|
250
|
+
const source = publicSource(record, ledger?.status ?? null);
|
|
251
|
+
if (ledger !== null && ledger.sourceDigest !== record.sourceDigest) {
|
|
252
|
+
// The source changed after its row was written. A later import must not treat the approved bytes and
|
|
253
|
+
// the current bytes as the same source; the ledger deliberately refuses to move that row.
|
|
254
|
+
source.problems.push('the source changed after its migration ledger row was written');
|
|
255
|
+
}
|
|
256
|
+
if (ledger?.status === 'complete') {
|
|
257
|
+
source.problems.push('this source is already imported; its legacy copy is retired');
|
|
258
|
+
}
|
|
259
|
+
sources.push(source);
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
generatedAt: new Date(now).toISOString(),
|
|
263
|
+
environmentId: authSwitchEnvironmentId(this.env),
|
|
264
|
+
notice: [...INVENTORY_NOTICE],
|
|
265
|
+
sources,
|
|
266
|
+
problems: [...report.problems],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Where every source the authority knows about stands.
|
|
272
|
+
*
|
|
273
|
+
* Read from the migration ledger, the grants and the handoffs only: it opens no legacy store, so it stays
|
|
274
|
+
* truthful about what the authority decided even if a source has since been moved or deleted on the host.
|
|
275
|
+
* A source that was never submitted has no ledger row and belongs to the inventory, not here.
|
|
276
|
+
*/
|
|
277
|
+
public async status(request: { after?: string; limit?: number } = {}): Promise<IAuthSwitchImportStatus> {
|
|
278
|
+
const page = await this.database.migrationLedgerPage(request.after ?? null, request.limit ?? 128);
|
|
279
|
+
const entries: IAuthSwitchImportStatusEntry[] = [];
|
|
280
|
+
for (const row of page.rows) {
|
|
281
|
+
const grant = row.grantId === null ? null : await this.database.readGrant(row.grantId);
|
|
282
|
+
const handoff = await this.database.readHandoff(importHandoffId(row.id));
|
|
283
|
+
entries.push({
|
|
284
|
+
sourceId: row.id, sourceKind: row.sourceKind, status: row.status,
|
|
285
|
+
accountId: row.accountId, loginId: row.grantId, owner: grant?.owner ?? null,
|
|
286
|
+
action: importAction(row.status, handoff), statusObservedAt: row.statusObservedAt,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return { generatedAt: this.stamp(), entries, nextCursor: page.nextCursor };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Imports one source.
|
|
294
|
+
*
|
|
295
|
+
* A source whose grant the authority takes over is verified by one real refresh, which rotates its token
|
|
296
|
+
* and is the point of no return; a source a native tool keeps refreshing is verified by a provider read
|
|
297
|
+
* that rotates nothing. Every step before the refresh is durable, and the marker that says "the exchange
|
|
298
|
+
* may have been sent" is the handoff's own `source_stopped` phase: a restart that finds it never replays
|
|
299
|
+
* the grant, it asks for a device sign-in instead.
|
|
300
|
+
*/
|
|
301
|
+
public async submit(request: { submission: TAuthSwitchImportSubmission; callerQuiescent: true;
|
|
302
|
+
acknowledgeRunOrder: true }): Promise<IAuthSwitchImportResult> {
|
|
303
|
+
if (request.acknowledgeRunOrder !== true) refuse(RUN_ORDER_REFUSAL);
|
|
304
|
+
if (request.callerQuiescent !== true) {
|
|
305
|
+
refuse('Importing needs the caller\'s assertion that nothing it owns will use this source meanwhile: '
|
|
306
|
+
+ 'a host that drives the legacy library inside its own process cannot be observed from here.');
|
|
307
|
+
}
|
|
308
|
+
const record = this.resolveSource(request.submission);
|
|
309
|
+
const plan = grantPlan(record);
|
|
310
|
+
this.assertQuiescent(record);
|
|
311
|
+
const ledger = await this.database.readMigrationLedger(record.id);
|
|
312
|
+
if (ledger !== null) {
|
|
313
|
+
if (ledger.sourceDigest !== record.sourceDigest) {
|
|
314
|
+
refuse('The source changed after its migration ledger row was written; it is a different source now.');
|
|
315
|
+
}
|
|
316
|
+
if (ledger.status === 'complete') refuse('This source is already imported.');
|
|
317
|
+
if (ledger.status === 'quarantined') {
|
|
318
|
+
refuse('This source is quarantined: its outcome could not be established, so it needs a device '
|
|
319
|
+
+ 'sign-in for that account rather than another import.');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const accountId = record.accountId;
|
|
323
|
+
const subject = record.subject;
|
|
324
|
+
const workspaceId = record.workspaceId;
|
|
325
|
+
if (accountId === null || subject === null || workspaceId === null) {
|
|
326
|
+
refuse('The source carries no provider identity the authority can bind an account to.');
|
|
327
|
+
}
|
|
328
|
+
const grantId = importGrantId(accountId, plan);
|
|
329
|
+
if (ledger === null) await this.beginLedger(record);
|
|
330
|
+
const material = this.material(record, request.submission);
|
|
331
|
+
return record.treatment === 'project'
|
|
332
|
+
? this.project(record, plan, { accountId, subject, workspaceId, grantId }, material)
|
|
333
|
+
: this.adopt(record, plan, { accountId, subject, workspaceId, grantId }, material);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The source a submission names, proven to be the one that was inventoried. */
|
|
337
|
+
private resolveSource(submission: TAuthSwitchImportSubmission): ILegacySourceRecord {
|
|
338
|
+
if (submission.kind === 'external') {
|
|
339
|
+
const identity = this.externalIdentity(submission.credential.idToken);
|
|
340
|
+
return {
|
|
341
|
+
id: idHash('authswitch-migration-v1', 'agl_flex', submission.sourcePathHash),
|
|
342
|
+
sourceKind: 'agl_flex', path: '', sourcePathHash: submission.sourcePathHash,
|
|
343
|
+
sourceDigest: submission.sourceDigest, treatment: 'import', proof: 'rotating_refresh',
|
|
344
|
+
accountId: identity.accountId, subject: identity.subject, workspaceId: identity.workspaceId,
|
|
345
|
+
providerId: 'openai', email: identity.email, plan: identity.plan, label: identity.email,
|
|
346
|
+
accessExpiresAt: null, enrollmentCount: null, problems: [],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const report = readLegacySources({ env: this.env, homeDirectory: this.homeDirectory, now: this.now() });
|
|
350
|
+
const record = report.sources.find(source => source.id === submission.sourceId);
|
|
351
|
+
if (!record) refuse('That source no longer exists on this host.');
|
|
352
|
+
if (record.sourceDigest !== submission.sourceDigest) {
|
|
353
|
+
refuse('The source changed after it was inventoried; review the inventory again before importing.');
|
|
354
|
+
}
|
|
355
|
+
if (record.problems.length) refuse(`The source cannot be imported: ${record.problems.join('; ')}.`);
|
|
356
|
+
if (record.treatment === 'refuse') refuse('This source is not a login the authority can hold.');
|
|
357
|
+
return record;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private externalIdentity(idToken: string): { accountId: string; subject: string; workspaceId: string;
|
|
361
|
+
email: string | null; plan: string | null } {
|
|
362
|
+
let info: ReturnType<typeof plugins.flexAuth.parseOpenAiChatGptTokenInfo>;
|
|
363
|
+
try { info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(idToken); }
|
|
364
|
+
catch { return refuse('The submitted login carries no readable ChatGPT identity.'); }
|
|
365
|
+
if (!info.chatgptUserId || !info.chatgptAccountId) {
|
|
366
|
+
refuse('The submitted login omits a stable ChatGPT user or account identity.');
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
accountId: idHash('authswitch-account-v1', 'openai', info.chatgptUserId, info.chatgptAccountId),
|
|
370
|
+
subject: info.chatgptUserId, workspaceId: info.chatgptAccountId,
|
|
371
|
+
email: info.email?.toLowerCase() ?? null, plan: info.chatgptPlanType ?? null,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Reads the credential the daemon will seal. Only an external submission brings its own. */
|
|
376
|
+
private material(record: ILegacySourceRecord,
|
|
377
|
+
submission: TAuthSwitchImportSubmission): TLegacySourceMaterial {
|
|
378
|
+
if (submission.kind === 'external') {
|
|
379
|
+
return { providerId: 'openai', credential: { kind: 'chatgptOAuth', providerId: 'openai',
|
|
380
|
+
accessToken: submission.credential.accessToken, refreshToken: submission.credential.refreshToken,
|
|
381
|
+
idToken: submission.credential.idToken } };
|
|
382
|
+
}
|
|
383
|
+
try { return readLegacySourceMaterial(record); }
|
|
384
|
+
catch (error) {
|
|
385
|
+
if (error instanceof LegacySourceChangedError) refuse(error.message);
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Refuses while anything that could refresh this source is running.
|
|
392
|
+
*
|
|
393
|
+
* Unknown counts as running: an import that guessed idle could rotate a token another owner is using.
|
|
394
|
+
*/
|
|
395
|
+
private assertQuiescent(record: ILegacySourceRecord): void {
|
|
396
|
+
const watch = this.quiescence.watchHolderPid();
|
|
397
|
+
if (watch !== null) {
|
|
398
|
+
refuse(`An authswitch watch is running (pid ${watch}); it switches logins. Stop it and import again.`);
|
|
399
|
+
}
|
|
400
|
+
const binary: THarnessBinary | null = record.providerId === 'anthropic' ? 'claude'
|
|
401
|
+
: record.sourceKind === 'opencode_native' ? 'opencode' : null;
|
|
402
|
+
if (binary !== null) {
|
|
403
|
+
const pids = this.quiescence.runningHarnessPids(binary);
|
|
404
|
+
if (pids.length) {
|
|
405
|
+
refuse(`${binary} is running (pid ${pids.join(', ')}) and owns this login. Close it and import again.`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (record.sourceKind === 'codex_native'
|
|
409
|
+
|| (record.sourceKind === 'authswitch_stash' && record.providerId === 'openai')) {
|
|
410
|
+
const running = this.quiescence.codexAppServerRunning();
|
|
411
|
+
if (running !== false) {
|
|
412
|
+
refuse(running === null
|
|
413
|
+
? 'Codex could not be asked whether its app-server is running, and an unknown app-server counts '
|
|
414
|
+
+ 'as running. Make Codex answerable and import again.'
|
|
415
|
+
: 'The Codex app-server is running and refreshes this login on its own schedule. '
|
|
416
|
+
+ 'Stop it with `codex app-server daemon stop` and import again.');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private async beginLedger(record: ILegacySourceRecord): Promise<void> {
|
|
422
|
+
const updateId = plugins.crypto.randomUUID();
|
|
423
|
+
// Written before any provider call, so a crash always leaves the source named in the authority.
|
|
424
|
+
await this.database.changeMigrationLedger(updateId, record.id, () => ({
|
|
425
|
+
id: record.id, version: 1, sourceKind: record.sourceKind, sourcePathHash: record.sourcePathHash,
|
|
426
|
+
sourceDigest: record.sourceDigest, status: 'prepared', accountId: null, grantId: null,
|
|
427
|
+
revision: 1, statusObservedAt: this.stamp(), updateId,
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private async moveLedger(ledgerId: string, status: IStoredAuthorityMigrationLedger['status'],
|
|
432
|
+
accountId: string | null, grantId: string | null): Promise<IStoredAuthorityMigrationLedger> {
|
|
433
|
+
const updateId = plugins.crypto.randomUUID();
|
|
434
|
+
return this.database.changeMigrationLedger(updateId, ledgerId, current => {
|
|
435
|
+
if (!current) throw new Error('The migration ledger row disappeared during the import.');
|
|
436
|
+
return { ...current, status, accountId, grantId, revision: current.revision + 1,
|
|
437
|
+
statusObservedAt: this.stamp(), updateId };
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private stamp(): string { return new Date(this.now()).toISOString(); }
|
|
442
|
+
|
|
443
|
+
private account(record: ILegacySourceRecord, identity: { accountId: string; subject: string;
|
|
444
|
+
workspaceId: string }, plan: IImportGrantPlan, existing: IStoredAuthorityAccount | null,
|
|
445
|
+
grantId: string, updateId: string): IStoredAuthorityAccount {
|
|
446
|
+
const label = record.label ?? record.email ?? `${plan.providerId} ${identity.workspaceId}`;
|
|
447
|
+
return existing
|
|
448
|
+
? { ...existing, label: existing.label || label, email: record.email, plan: record.plan,
|
|
449
|
+
removed: false, revision: existing.revision + 1, statusObservedAt: this.stamp(), updateId }
|
|
450
|
+
: { id: identity.accountId, providerId: plan.providerId, issuer: plan.providerId,
|
|
451
|
+
subject: identity.subject, workspaceId: identity.workspaceId, label, email: record.email,
|
|
452
|
+
plan: record.plan, primaryGrantId: grantId, removed: false, revision: 1,
|
|
453
|
+
statusObservedAt: this.stamp(), updateId };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Writes the account and its grant, choosing the primary or the secondary transaction by identity. */
|
|
457
|
+
private async putGrant(identity: { accountId: string; subject: string; workspaceId: string },
|
|
458
|
+
record: ILegacySourceRecord, plan: IImportGrantPlan, grantId: string,
|
|
459
|
+
make: (existing: IStoredAuthorityGrant | null, updateId: string) => IStoredAuthorityGrant): Promise<void> {
|
|
460
|
+
const existingAccount = await this.database.readAccount(identity.accountId);
|
|
461
|
+
const updateId = plugins.crypto.randomUUID();
|
|
462
|
+
if (existingAccount && existingAccount.primaryGrantId !== grantId) {
|
|
463
|
+
await this.database.changeSecondaryGrant(updateId, identity.accountId, grantId,
|
|
464
|
+
grant => make(grant, updateId));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
await this.database.changeAccountAndGrant(updateId, identity.accountId, grantId, (account, grant) => ({
|
|
468
|
+
account: this.account(record, identity, plan, account, grantId, updateId),
|
|
469
|
+
grant: make(grant, updateId),
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private baseGrant(identity: { accountId: string }, plan: IImportGrantPlan, grantId: string,
|
|
474
|
+
existing: IStoredAuthorityGrant | null, updateId: string,
|
|
475
|
+
overrides: Partial<IStoredAuthorityGrant>): IStoredAuthorityGrant {
|
|
476
|
+
return {
|
|
477
|
+
id: grantId, accountId: identity.accountId, providerId: plan.providerId, purpose: plan.purpose,
|
|
478
|
+
audience: plan.audience, state: 'legacy_native_pending', owner: 'legacy_native',
|
|
479
|
+
grantGeneration: existing ? existing.grantGeneration : 1,
|
|
480
|
+
authorizationGeneration: existing ? existing.authorizationGeneration : 1,
|
|
481
|
+
revision: existing ? existing.revision + 1 : 1, ciphertext: null, accessExpiresAt: null,
|
|
482
|
+
attemptId: null, retryAt: null, retryCount: 0, problem: 'native_owner',
|
|
483
|
+
statusObservedAt: this.stamp(), updateId, ...overrides,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Refuses a source whose grant another owner already holds, in either direction. */
|
|
488
|
+
private assertOwnable(existing: IStoredAuthorityGrant | null, plan: IImportGrantPlan): void {
|
|
489
|
+
if (!existing) return;
|
|
490
|
+
if (existing.owner === plan.settledOwner || existing.owner === 'none') return;
|
|
491
|
+
refuse(existing.owner === 'daemon'
|
|
492
|
+
? 'The authority already owns this login; a native store cannot take it over without a handoff.'
|
|
493
|
+
: 'A native store already owns this login. Its account is registered from that source; select it '
|
|
494
|
+
+ 'there rather than importing a second copy.');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A projected source: the account is registered, the grant stays with its native owner, and the proof is a
|
|
499
|
+
* provider read that rotates nothing. No refresh is ever sent here.
|
|
500
|
+
*/
|
|
501
|
+
private async project(record: ILegacySourceRecord, plan: IImportGrantPlan,
|
|
502
|
+
identity: { accountId: string; subject: string; workspaceId: string; grantId: string },
|
|
503
|
+
material: TLegacySourceMaterial): Promise<IAuthSwitchImportResult> {
|
|
504
|
+
this.assertOwnable(await this.database.readGrant(identity.grantId), plan);
|
|
505
|
+
await this.putGrant(identity, record, plan, identity.grantId, (existing, updateId) =>
|
|
506
|
+
this.baseGrant(identity, plan, identity.grantId, existing, updateId,
|
|
507
|
+
{ state: 'native', owner: plan.settledOwner }));
|
|
508
|
+
try { await this.proveWithoutRotation(record, identity, material); }
|
|
509
|
+
catch (error) {
|
|
510
|
+
// Not a quarantine: no rotating token was sent here, so nothing was consumed and nothing is lost. The
|
|
511
|
+
// account stays registered with its native owner, unproven, and the same source is submitted again
|
|
512
|
+
// once that native tool has refreshed its own store -- which is exactly what the refusal asks for.
|
|
513
|
+
await this.moveLedger(record.id, 'pending_native_owner', identity.accountId, identity.grantId);
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId);
|
|
517
|
+
if (record.sourceKind === 'claude_native') await this.adoptClaudeHome(record, identity);
|
|
518
|
+
await this.moveLedger(record.id, 'complete', identity.accountId, identity.grantId);
|
|
519
|
+
return this.result(record, 'complete', identity, []);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Adopts the native Claude home this source is, so the daemon may serve `claude.*` for it.
|
|
524
|
+
*
|
|
525
|
+
* The verified receipt is the only thing that may adopt a home, and the source id it was earned with is
|
|
526
|
+
* the home id, so nothing else has to be trusted here. Re-submitting the same source returns the same
|
|
527
|
+
* home record rather than writing a second one.
|
|
528
|
+
*/
|
|
529
|
+
private async adoptClaudeHome(record: ILegacySourceRecord,
|
|
530
|
+
identity: { accountId: string; subject: string; workspaceId: string; grantId: string }): Promise<void> {
|
|
531
|
+
await this.database.registerClaudeHomeFromReceipt({
|
|
532
|
+
homeId: record.sourcePathHash, accountId: identity.accountId, grantId: identity.grantId,
|
|
533
|
+
receiptId: record.id, sourceDigest: record.sourceDigest,
|
|
534
|
+
accountUuid: identity.subject, organizationUuid: identity.workspaceId,
|
|
535
|
+
updateId: plugins.crypto.randomUUID(), now: this.stamp(),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The non-rotating proof: the stored access token is asked about itself and must name this account. */
|
|
540
|
+
private async proveWithoutRotation(record: ILegacySourceRecord,
|
|
541
|
+
identity: { accountId: string; subject: string; workspaceId: string },
|
|
542
|
+
material: TLegacySourceMaterial): Promise<void> {
|
|
543
|
+
if (material.providerId === 'openai') {
|
|
544
|
+
const result = await this.openAi.getAccountRateLimits({ kind: 'chatgptAccess',
|
|
545
|
+
providerId: 'openai', accessToken: material.credential.accessToken,
|
|
546
|
+
accountId: identity.workspaceId, isFedrampAccount: false });
|
|
547
|
+
if (!result.success) {
|
|
548
|
+
refuse(`The stored access token could not be proven live (${result.errorCode}); let the native tool `
|
|
549
|
+
+ 'refresh its own store, then import again.');
|
|
550
|
+
}
|
|
551
|
+
// The claims of the very token that answered must still name this account.
|
|
552
|
+
const claims = plugins.flexAuth.parseOpenAiChatGptTokenInfo(material.credential.accessToken);
|
|
553
|
+
if (claims.chatgptAccountId !== identity.workspaceId) {
|
|
554
|
+
refuse('The stored access token belongs to another ChatGPT account.');
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const oauth = material.login.claudeAiOauth;
|
|
559
|
+
const accessToken = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
|
|
560
|
+
const scopes = Array.isArray(oauth.scopes)
|
|
561
|
+
? oauth.scopes.filter((scope): scope is string => typeof scope === 'string') : [];
|
|
562
|
+
const read = await this.claudeStatus.readDetailed({ accessToken, scopes,
|
|
563
|
+
identity: { accountUuid: identity.subject, organizationUuid: identity.workspaceId },
|
|
564
|
+
...(record.plan === null ? {} : { plan: record.plan }) });
|
|
565
|
+
if (read.profile === 'mismatch') refuse('The native Claude login belongs to another account.');
|
|
566
|
+
if (read.profile !== 'verified') {
|
|
567
|
+
refuse(`The native Claude login could not be proven live (profile ${read.profile}); let Claude Code `
|
|
568
|
+
+ 'refresh its own store, then import again.');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* A source the authority takes over.
|
|
574
|
+
*
|
|
575
|
+
* The handoff row is the durable record of how far this went. `source_stopped` is written before the
|
|
576
|
+
* refresh is sent, so a lost answer is always visible afterwards and is never retried.
|
|
577
|
+
*/
|
|
578
|
+
private async adopt(record: ILegacySourceRecord, plan: IImportGrantPlan,
|
|
579
|
+
identity: { accountId: string; subject: string; workspaceId: string; grantId: string },
|
|
580
|
+
material: TLegacySourceMaterial): Promise<IAuthSwitchImportResult> {
|
|
581
|
+
const handoffId = importHandoffId(record.id);
|
|
582
|
+
let handoff = await this.database.readHandoff(handoffId);
|
|
583
|
+
if (handoff === null) {
|
|
584
|
+
this.assertOwnable(await this.database.readGrant(identity.grantId), plan);
|
|
585
|
+
const ciphertext = await this.seal(identity.grantId, material);
|
|
586
|
+
await this.putGrant(identity, record, plan, identity.grantId, (existing, updateId) =>
|
|
587
|
+
this.baseGrant(identity, plan, identity.grantId, existing, updateId, { ciphertext }));
|
|
588
|
+
handoff = await this.moveHandoff(handoffId, record, identity, 'prepared',
|
|
589
|
+
grant => ({ ...grant, state: 'legacy_native_pending' }));
|
|
590
|
+
}
|
|
591
|
+
if (handoff.phase === 'needs_reauth' || handoff.phase === 'aborted') {
|
|
592
|
+
await this.quarantine(record, identity);
|
|
593
|
+
refuse('A previous import of this source did not settle; sign in to that account again instead.');
|
|
594
|
+
}
|
|
595
|
+
if (handoff.phase === 'prepared') {
|
|
596
|
+
// The durable marker. Everything after it may have reached the provider.
|
|
597
|
+
handoff = await this.moveHandoff(handoffId, record, identity, 'source_stopped',
|
|
598
|
+
grant => ({ ...grant, state: 'legacy_native_pending' }));
|
|
599
|
+
const rotated = await this.rotate(material);
|
|
600
|
+
if (rotated === null) {
|
|
601
|
+
await this.failHandoff(handoffId, record, identity);
|
|
602
|
+
await this.quarantine(record, identity);
|
|
603
|
+
refuse('The sign-in service did not give a usable answer, and a rotating refresh token is never '
|
|
604
|
+
+ 'sent twice. Sign in to that account again to finish this import.');
|
|
605
|
+
}
|
|
606
|
+
const ciphertext = await this.seal(identity.grantId, rotated.material);
|
|
607
|
+
handoff = await this.moveHandoff(handoffId, record, identity, 'target_written', grant => ({
|
|
608
|
+
...grant, ciphertext, accessExpiresAt: rotated.accessExpiresAt,
|
|
609
|
+
grantGeneration: grant.grantGeneration + 1, state: 'legacy_native_pending',
|
|
610
|
+
}));
|
|
611
|
+
}
|
|
612
|
+
if (handoff.phase === 'source_stopped') {
|
|
613
|
+
// Reached only by a restart: the exchange may have been sent, so the grant is never replayed.
|
|
614
|
+
await this.failHandoff(handoffId, record, identity);
|
|
615
|
+
await this.quarantine(record, identity);
|
|
616
|
+
refuse('This import was interrupted while its sign-in was in flight. A rotating refresh token is '
|
|
617
|
+
+ 'never sent twice; sign in to that account again.');
|
|
618
|
+
}
|
|
619
|
+
if (handoff.phase === 'target_written') {
|
|
620
|
+
handoff = await this.moveHandoff(handoffId, record, identity, 'verified',
|
|
621
|
+
grant => ({ ...grant, state: 'legacy_native_pending' }));
|
|
622
|
+
}
|
|
623
|
+
if (handoff.phase === 'verified') {
|
|
624
|
+
await this.moveHandoff(handoffId, record, identity, 'committed', grant => ({
|
|
625
|
+
...grant, state: 'ready', owner: 'daemon', problem: 'none',
|
|
626
|
+
authorizationGeneration: grant.authorizationGeneration + 1,
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
const ledger = await this.database.readMigrationLedger(record.id);
|
|
630
|
+
if (ledger?.status !== 'verified' && ledger?.status !== 'complete') {
|
|
631
|
+
await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId);
|
|
632
|
+
}
|
|
633
|
+
await this.storeEnrollments(record, identity);
|
|
634
|
+
if ((await this.database.readMigrationLedger(record.id))?.status !== 'complete') {
|
|
635
|
+
await this.moveLedger(record.id, 'complete', identity.accountId, identity.grantId);
|
|
636
|
+
}
|
|
637
|
+
return this.result(record, 'complete', identity, []);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private async moveHandoff(handoffId: string, record: ILegacySourceRecord,
|
|
641
|
+
identity: { accountId: string; grantId: string }, phase: IStoredAuthorityHandoff['phase'],
|
|
642
|
+
grantChange: (grant: IStoredAuthorityGrant) => IStoredAuthorityGrant): Promise<IStoredAuthorityHandoff> {
|
|
643
|
+
const updateId = plugins.crypto.randomUUID();
|
|
644
|
+
const result = await this.database.changeHandoffAndGrant(updateId, handoffId, identity.grantId,
|
|
645
|
+
(current, grant) => ({
|
|
646
|
+
handoff: {
|
|
647
|
+
id: handoffId, accountId: identity.accountId, grantId: identity.grantId,
|
|
648
|
+
nativeHomeId: record.sourcePathHash, direction: 'to_daemon', phase,
|
|
649
|
+
operationId: current?.operationId ?? plugins.crypto.randomUUID(),
|
|
650
|
+
sourceDigest: record.sourceDigest,
|
|
651
|
+
verifiedSourceDigest: ['verified', 'committed'].includes(phase) ? record.sourceDigest
|
|
652
|
+
: current?.verifiedSourceDigest ?? null,
|
|
653
|
+
revision: current ? current.revision + 1 : 1, statusObservedAt: this.stamp(), updateId,
|
|
654
|
+
},
|
|
655
|
+
grant: { ...grantChange(grant), revision: grant.revision + 1,
|
|
656
|
+
statusObservedAt: this.stamp(), updateId },
|
|
657
|
+
}));
|
|
658
|
+
return result.handoff;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private async failHandoff(handoffId: string, record: ILegacySourceRecord,
|
|
662
|
+
identity: { accountId: string; grantId: string }): Promise<void> {
|
|
663
|
+
await this.moveHandoff(handoffId, record, identity, 'needs_reauth', grant => ({
|
|
664
|
+
...grant, state: 'needs_reauth', owner: 'none', ciphertext: null, accessExpiresAt: null,
|
|
665
|
+
problem: 'exchange_uncertain',
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Marks a source whose sign-in exchange may already have been consumed. That is its only meaning, so the
|
|
671
|
+
* refusal a later submission gets needs no further reason: the repair is always a device sign-in.
|
|
672
|
+
*/
|
|
673
|
+
private async quarantine(record: ILegacySourceRecord,
|
|
674
|
+
identity: { accountId: string; grantId: string }): Promise<void> {
|
|
675
|
+
const ledger = await this.database.readMigrationLedger(record.id);
|
|
676
|
+
if (!ledger || ledger.status === 'complete' || ledger.status === 'quarantined') return;
|
|
677
|
+
// A quarantined row keeps the account and grant it names, so the owner knows which account to sign in to.
|
|
678
|
+
await this.moveLedger(record.id, 'quarantined', identity.accountId, identity.grantId);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The one rotating refresh. `null` means the outcome is unknown and the grant must never be replayed.
|
|
683
|
+
*
|
|
684
|
+
* A thrown error is the same answer as a failed one: once the request left this process, whether the
|
|
685
|
+
* provider consumed the token is unknowable, so both end in a device sign-in rather than a retry.
|
|
686
|
+
*/
|
|
687
|
+
private async rotate(material: TLegacySourceMaterial):
|
|
688
|
+
Promise<{ material: TLegacySourceMaterial; accessExpiresAt: string | null } | null> {
|
|
689
|
+
try { return await this.rotateOnce(material); }
|
|
690
|
+
catch { return null; }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private async rotateOnce(material: TLegacySourceMaterial):
|
|
694
|
+
Promise<{ material: TLegacySourceMaterial; accessExpiresAt: string | null } | null> {
|
|
695
|
+
if (material.providerId === 'openai') {
|
|
696
|
+
const result = await this.openAi.refreshCredential(material.credential);
|
|
697
|
+
if (!result.success) return null;
|
|
698
|
+
let accessExpiresAt: string | null = null;
|
|
699
|
+
try { accessExpiresAt = plugins.flexAuth.parseOpenAiChatGptTokenInfo(result.credential.accessToken).expiresAt ?? null; }
|
|
700
|
+
catch { accessExpiresAt = null; }
|
|
701
|
+
return { material: { providerId: 'openai', credential: { kind: 'chatgptOAuth', providerId: 'openai',
|
|
702
|
+
accessToken: result.credential.accessToken, refreshToken: result.credential.refreshToken,
|
|
703
|
+
idToken: result.credential.idToken } }, accessExpiresAt };
|
|
704
|
+
}
|
|
705
|
+
const outcome = await this.claudeTokens.refreshForAuthority(material.login.claudeAiOauth);
|
|
706
|
+
if (!outcome.success) return null;
|
|
707
|
+
return {
|
|
708
|
+
material: { providerId: 'anthropic', login: { ...material.login,
|
|
709
|
+
claudeAiOauth: { ...material.login.claudeAiOauth, accessToken: outcome.tokens.accessToken,
|
|
710
|
+
refreshToken: outcome.tokens.refreshToken, expiresAt: outcome.tokens.expiresAt,
|
|
711
|
+
scopes: outcome.tokens.scopes } } },
|
|
712
|
+
accessExpiresAt: new Date(outcome.tokens.expiresAt).toISOString(),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private async seal(grantId: string, material: TLegacySourceMaterial): Promise<string> {
|
|
717
|
+
const payload = material.providerId === 'openai' ? material.credential : material.login;
|
|
718
|
+
const bytes = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
719
|
+
try { return await this.secrets.seal(grantId, bytes); }
|
|
720
|
+
finally { bytes.fill(0); }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Codex remote-control enrollments travel with the credential; losing them is losing a pairing. */
|
|
724
|
+
private async storeEnrollments(record: ILegacySourceRecord,
|
|
725
|
+
identity: { accountId: string }): Promise<void> {
|
|
726
|
+
if (record.sourceKind !== 'authswitch_stash' || !record.enrollmentCount) return;
|
|
727
|
+
const rows = readLegacySourceEnrollments(record);
|
|
728
|
+
if (!rows.length) return;
|
|
729
|
+
const enrollmentId = idHash('authswitch-enrollment-v1', identity.accountId);
|
|
730
|
+
const updateId = plugins.crypto.randomUUID();
|
|
731
|
+
const bytes = Buffer.from(JSON.stringify(rows), 'utf8');
|
|
732
|
+
let ciphertext: string;
|
|
733
|
+
try { ciphertext = await this.secrets.seal(identity.accountId, bytes); }
|
|
734
|
+
finally { bytes.fill(0); }
|
|
735
|
+
await this.database.changeEnrollment(updateId, enrollmentId, identity.accountId, current => ({
|
|
736
|
+
id: enrollmentId, accountId: identity.accountId, ciphertext,
|
|
737
|
+
revision: current ? current.revision + 1 : 1, updateId,
|
|
738
|
+
}));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private result(record: ILegacySourceRecord, status: IAuthSwitchImportResult['status'],
|
|
742
|
+
identity: { accountId: string; grantId: string }, problems: string[]): IAuthSwitchImportResult {
|
|
743
|
+
return { sourceId: record.id, sourceKind: record.sourceKind, treatment: record.treatment, status,
|
|
744
|
+
accountId: identity.accountId, loginId: identity.grantId, problems };
|
|
745
|
+
}
|
|
746
|
+
}
|