@aithos/sdk 0.1.0-alpha.3 → 0.1.0-alpha.5

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.
Files changed (46) hide show
  1. package/dist/src/auth-api.d.ts +41 -0
  2. package/dist/src/auth-api.js +82 -0
  3. package/dist/src/auth.d.ts +114 -75
  4. package/dist/src/auth.js +553 -73
  5. package/dist/src/compute.d.ts +8 -6
  6. package/dist/src/compute.js +19 -11
  7. package/dist/src/ethos.d.ts +117 -1
  8. package/dist/src/ethos.js +417 -16
  9. package/dist/src/index.d.ts +8 -4
  10. package/dist/src/index.js +26 -8
  11. package/dist/src/internal/delegate-bundle.d.ts +18 -0
  12. package/dist/src/internal/delegate-bundle.js +89 -0
  13. package/dist/src/internal/delegate-state.d.ts +45 -0
  14. package/dist/src/internal/delegate-state.js +120 -0
  15. package/dist/src/internal/owner-signers.d.ts +78 -0
  16. package/dist/src/internal/owner-signers.js +179 -0
  17. package/dist/src/internal/protocol-client-bridge.d.ts +8 -0
  18. package/dist/src/internal/protocol-client-bridge.js +20 -0
  19. package/dist/src/internal/recovery-file.d.ts +29 -0
  20. package/dist/src/internal/recovery-file.js +98 -0
  21. package/dist/src/internal/signer.d.ts +59 -0
  22. package/dist/src/internal/signer.js +86 -0
  23. package/dist/src/key-store.d.ts +128 -0
  24. package/dist/src/key-store.js +244 -0
  25. package/dist/src/mandates.d.ts +88 -1
  26. package/dist/src/mandates.js +185 -8
  27. package/dist/src/sdk.d.ts +36 -3
  28. package/dist/src/sdk.js +27 -23
  29. package/dist/src/session-store.d.ts +58 -0
  30. package/dist/src/session-store.js +158 -0
  31. package/dist/src/wallet.d.ts +4 -6
  32. package/dist/src/wallet.js +18 -8
  33. package/dist/test/auth-j3.test.d.ts +2 -0
  34. package/dist/test/auth-j3.test.js +360 -0
  35. package/dist/test/compute.test.js +22 -11
  36. package/dist/test/ethos.test.d.ts +2 -0
  37. package/dist/test/ethos.test.js +219 -0
  38. package/dist/test/key-store.test.d.ts +2 -0
  39. package/dist/test/key-store.test.js +161 -0
  40. package/dist/test/mandates.test.d.ts +2 -0
  41. package/dist/test/mandates.test.js +93 -0
  42. package/dist/test/sdk.test.js +64 -30
  43. package/dist/test/signer.test.d.ts +2 -0
  44. package/dist/test/signer.test.js +117 -0
  45. package/dist/test/wallet.test.js +20 -9
  46. package/package.json +4 -3
package/dist/src/auth.js CHANGED
@@ -1,63 +1,434 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright 2026 Mathieu Colla
3
- // Sign in with Google for Aithos apps.
3
+ // Aithos auth — sign-up, sign-in (email+password / Google SSO /
4
+ // recovery file), mandate import, sign-out.
4
5
  //
5
- // The auth flow is intentionally separate from {@link AithosSDK}: at sign-in
6
- // time the caller doesn't have a `BrowserIdentity` yet that's precisely
7
- // what the flow returns (via the encrypted blob the response carries). So
8
- // we expose a standalone {@link AithosAuth} class that talks to the Aithos
9
- // auth backend (`auth.aithos.be`) over plain HTTPS.
6
+ // One stateful object per app. Holds the active {@link AithosSession}
7
+ // (JWT-backed when present), the loaded owner signers in memory (when
8
+ // signed in as an owner), and a registry of active delegate
9
+ // sessions. Persists across reloads via the configured
10
+ // {@link AithosSessionStore} (JWT) and {@link AithosKeyStore} (signing
11
+ // material).
10
12
  //
11
- // Flow from the caller's point of view:
13
+ // Strict mode (per design discussion): the two stores must agree about
14
+ // who's signed in. If sessionStore has a JWT for one DID and keyStore
15
+ // has an owner with a DIFFERENT DID, both are wiped. If sessionStore
16
+ // has a JWT but keyStore has no owner at all, the JWT is wiped (a
17
+ // JWT alone is useless without local signing capability for everything
18
+ // past compute/wallet).
12
19
  //
13
- // const auth = new AithosAuth();
14
- // // on a "Sign in" button:
15
- // auth.signInWithGoogle({ appState: "/dashboard" }); // navigates away
16
- //
17
- // // on the redirect-back page (e.g. /auth/callback):
18
- // const session = await auth.handleCallback();
19
- // if (session) {
20
- // // session.session — JWT, send as Bearer to /auth/blob etc.
21
- // // session.enc_key_b64 32-byte vault key, base64 (raw bytes for
22
- // // AES-GCM decryption of session.blob_b64)
23
- // // session.is_first_login true on the very first sign-in
24
- // }
25
- //
26
- // We deliberately don't persist anything in this module — storage is the
27
- // app's call (its threat model, its choices around localStorage vs
28
- // sessionStorage vs IndexedDB). See README "Auth — sessions and storage"
29
- // for the recommended patterns.
20
+ // JWT-less sessions (recovery / mandate sign-ins) are valid: the
21
+ // keyStore is the source of truth for "is the user signed in", the
22
+ // JWT is auxiliary for compute/wallet.
23
+ import { buildBlobPlaintext, createBrowserIdentity, decryptBlob, DEFAULT_KDF, deriveAuthAndEncKeys, encryptBlob, parseBlob, randomNonce, randomSalt, serializeBlob, zeroize, } from "@aithos/protocol-client";
24
+ import { loginChallenge, loginVerify, registerAccount, } from "./auth-api.js";
25
+ import { defaultSessionStore, } from "./session-store.js";
26
+ import { defaultKeyStore, } from "./key-store.js";
27
+ import { parseDelegateBundle, readDelegateBundleText, } from "./internal/delegate-bundle.js";
28
+ import { DelegateActor, DelegateRegistry, } from "./internal/delegate-state.js";
29
+ import { OwnerSigners } from "./internal/owner-signers.js";
30
+ import { parseRecoveryFile, readRecoveryFileText, serializeRecoveryFile, } from "./internal/recovery-file.js";
30
31
  import { AithosSDKError } from "./types.js";
31
32
  /** Default URL of the Aithos auth backend. */
32
33
  export const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
33
34
  /* -------------------------------------------------------------------------- */
34
35
  /* AithosAuth */
35
36
  /* -------------------------------------------------------------------------- */
36
- /**
37
- * Authenticator for the Aithos identity service. One instance per app
38
- * is the recommended pattern (the constructor is cheap; it just trims the
39
- * URL). All methods are pure — no module-global state.
40
- */
41
37
  export class AithosAuth {
42
- /** Resolved auth base URL with a trailing slash trimmed. */
43
38
  authBaseUrl;
44
- fetchImpl;
45
- win;
39
+ #fetchImpl;
40
+ #win;
41
+ #sessionStore;
42
+ #keyStore;
43
+ /** In-memory owner signers — populated after sign-in or `resume`. */
44
+ #ownerSigners = null;
45
+ /** Active delegate registry. */
46
+ #delegates = new DelegateRegistry();
46
47
  constructor(config = {}) {
47
48
  this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
48
- this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
49
- this.win = config.window ?? (typeof window !== "undefined" ? window : undefined);
49
+ this.#fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
50
+ this.#win =
51
+ config.window ??
52
+ (typeof window !== "undefined" ? window : undefined);
53
+ this.#sessionStore = config.sessionStore ?? defaultSessionStore();
54
+ this.#keyStore = config.keyStore ?? defaultKeyStore();
55
+ }
56
+ /* ------------------------------------------------------------------------ */
57
+ /* Boot-time hydration */
58
+ /* ------------------------------------------------------------------------ */
59
+ /**
60
+ * Reload signing material and JWT session from the configured stores.
61
+ * Must be called once at app boot before relying on
62
+ * {@link getCurrentSession} / {@link getOwnerInfo} / {@link canSignAsOwner}
63
+ * — until then they reflect only what's been done in-memory in the
64
+ * current tab.
65
+ *
66
+ * Strict consistency: if the JWT and the stored owner disagree about
67
+ * who's signed in, both are wiped and the user re-auths. JWT-less
68
+ * owner state (loaded from keyStore but no JWT) is a valid resumed
69
+ * state — the user signed in via recovery or imported a mandate at
70
+ * some earlier moment and never went through the JWT flow.
71
+ */
72
+ async resume() {
73
+ // 1. Owner side.
74
+ const stored = await this.#keyStore.loadOwner().catch(() => null);
75
+ const jwt = this.#sessionStore.get();
76
+ if (stored) {
77
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
78
+ // JWT must match the owner DID — otherwise it's stale state.
79
+ if (jwt && jwt.did !== stored.did) {
80
+ this.#sessionStore.clear();
81
+ }
82
+ }
83
+ else {
84
+ // No owner persisted. A lingering JWT is meaningless without local
85
+ // signing capability — wipe it (strict mode).
86
+ if (jwt)
87
+ this.#sessionStore.clear();
88
+ }
89
+ // 2. Delegate side. Independent of owner state — a user may hold
90
+ // only delegate bundles and no owner identity at all.
91
+ const storedDelegates = await this.#keyStore
92
+ .listDelegates()
93
+ .catch(() => []);
94
+ for (const d of storedDelegates) {
95
+ try {
96
+ this.#delegates.add(DelegateActor.fromStored(d));
97
+ }
98
+ catch {
99
+ // Skip corrupted entries silently — the keystore validators
100
+ // already filter most of these. A skip here means the keystore
101
+ // record passed validation but the seed couldn't be re-derived
102
+ // (e.g. zero-length, future migration); ignore and continue.
103
+ }
104
+ }
105
+ }
106
+ /* ------------------------------------------------------------------------ */
107
+ /* State accessors */
108
+ /* ------------------------------------------------------------------------ */
109
+ /** JWT-backed session. Null when signed in via recovery / mandate / not at all. */
110
+ getCurrentSession() {
111
+ const session = this.#sessionStore.get();
112
+ if (!session)
113
+ return null;
114
+ // Belt-and-braces: the session store auto-evicts on expiry, but we
115
+ // also re-check here in case the in-tab clock drifted post-load.
116
+ return session;
117
+ }
118
+ /** Loaded owner identity. Independent of JWT presence. */
119
+ getOwnerInfo() {
120
+ if (!this.#ownerSigners)
121
+ return null;
122
+ return {
123
+ did: this.#ownerSigners.did,
124
+ handle: this.#ownerSigners.handle,
125
+ displayName: this.#ownerSigners.displayName,
126
+ };
127
+ }
128
+ getDelegates() {
129
+ return this.#delegates.list().map(actorToInfo);
130
+ }
131
+ canSignAsOwner() {
132
+ return this.#ownerSigners !== null && !this.#ownerSigners.destroyed;
133
+ }
134
+ canSignAsDelegateFor(did) {
135
+ const a = this.#delegates.findForSubject(did);
136
+ return a !== undefined && !a.destroyed;
137
+ }
138
+ /**
139
+ * Internal accessor used by sibling SDK namespaces (compute, wallet,
140
+ * ethos) when they need to sign on behalf of the owner. Returns null
141
+ * if no owner is loaded.
142
+ *
143
+ * @internal
144
+ */
145
+ _getOwnerSigners() {
146
+ return this.#ownerSigners;
147
+ }
148
+ /**
149
+ * Internal accessor — looks up an active delegate by mandate id.
150
+ * @internal
151
+ */
152
+ _getDelegateActor(mandateId) {
153
+ return this.#delegates.get(mandateId);
154
+ }
155
+ /**
156
+ * Internal accessor — finds the first active delegate whose subject
157
+ * matches `did`. Used by `sdk.ethos.of(did)` when the user holds a
158
+ * mandate for that subject.
159
+ * @internal
160
+ */
161
+ _findDelegateForSubject(did) {
162
+ return this.#delegates.findForSubject(did);
163
+ }
164
+ /* ------------------------------------------------------------------------ */
165
+ /* Email + password — signIn */
166
+ /* ------------------------------------------------------------------------ */
167
+ async signIn(input) {
168
+ if (!input.email || !input.password) {
169
+ throw new AithosSDKError("auth_invalid_input", "signIn: email and password are required");
170
+ }
171
+ const challenge = await loginChallenge({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
172
+ const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, challenge.authSalt, challenge.encSalt, challenge.kdf);
173
+ let verify;
174
+ try {
175
+ verify = await loginVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
176
+ }
177
+ catch (e) {
178
+ // On failure, both keys must be wiped before propagating.
179
+ zeroize(authKey);
180
+ zeroize(encKey);
181
+ throw e;
182
+ }
183
+ zeroize(authKey);
184
+ // Decrypt the vault blob → plaintext seeds + delegate bundles.
185
+ let plaintext;
186
+ try {
187
+ const blobBytes = decryptBlob(encKey, verify.blobNonce, verify.blob);
188
+ try {
189
+ plaintext = parseBlob(blobBytes);
190
+ }
191
+ finally {
192
+ zeroize(blobBytes);
193
+ }
194
+ }
195
+ catch (e) {
196
+ zeroize(encKey);
197
+ throw new AithosSDKError("auth_blob_decrypt_failed", `Could not decrypt the vault blob: ${e.message}`);
198
+ }
199
+ zeroize(encKey);
200
+ // Sanity check: blob's identity must agree with what verify returned.
201
+ if (plaintext.identity.did !== verify.did) {
202
+ throw new AithosSDKError("auth_blob_identity_mismatch", "vault blob's DID does not match the verified login DID", { data: { blobDid: plaintext.identity.did, verifyDid: verify.did } });
203
+ }
204
+ // Hydrate in-memory state.
205
+ if (this.#ownerSigners)
206
+ this.#ownerSigners.destroy();
207
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
208
+ // Persist to keyStore — owner first, then delegates.
209
+ const ownerStored = {
210
+ version: "0.1.0-hex",
211
+ did: plaintext.identity.did,
212
+ handle: plaintext.identity.handle,
213
+ displayName: plaintext.identity.displayName,
214
+ seedsHex: plaintext.seeds,
215
+ savedAt: new Date().toISOString(),
216
+ };
217
+ await this.#keyStore.saveOwner(ownerStored);
218
+ // Replace any prior delegate set with what the blob carries.
219
+ await this.#keyStore.clearAllDelegates();
220
+ this.#delegates.destroy();
221
+ for (const d of plaintext.delegates) {
222
+ const stored = storedDelegateFromBlob(d);
223
+ try {
224
+ await this.#keyStore.saveDelegate(stored);
225
+ }
226
+ catch {
227
+ // Persistence failure shouldn't block the sign-in. We still load
228
+ // the actor in memory so the session works for the current tab.
229
+ }
230
+ try {
231
+ this.#delegates.add(DelegateActor.fromStored(stored));
232
+ }
233
+ catch {
234
+ // Skip silently — keep going on remaining delegates.
235
+ }
236
+ }
237
+ const session = {
238
+ session: verify.session,
239
+ exp: verify.exp,
240
+ did: verify.did,
241
+ handle: verify.handle,
242
+ blob_b64: bytesToB64Public(verify.blob),
243
+ blob_nonce_b64: bytesToB64Public(verify.blobNonce),
244
+ blob_version: verify.blobVersion,
245
+ enc_key_b64: "",
246
+ is_first_login: false,
247
+ };
248
+ this.#sessionStore.set(session);
249
+ return session;
50
250
  }
251
+ /* ------------------------------------------------------------------------ */
252
+ /* Email + password — signUp */
253
+ /* ------------------------------------------------------------------------ */
254
+ async signUp(input) {
255
+ if (!input.email || !input.password) {
256
+ throw new AithosSDKError("auth_invalid_input", "signUp: email and password are required");
257
+ }
258
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(input.handle)) {
259
+ throw new AithosSDKError("auth_invalid_handle", "signUp: handle must be 1–63 alphanumeric chars + _ -");
260
+ }
261
+ const displayName = input.displayName ?? input.handle;
262
+ const identity = createBrowserIdentity(input.handle, displayName);
263
+ const recoverySerialized = serializeRecoveryFile(identity);
264
+ const recoveryFile = new Blob([recoverySerialized.text], {
265
+ type: "application/json",
266
+ });
267
+ // Derive password-based keys, encrypt the vault blob.
268
+ const authSalt = randomSalt();
269
+ const encSalt = randomSalt();
270
+ const kdf = DEFAULT_KDF;
271
+ const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, authSalt, encSalt, kdf);
272
+ const plaintext = buildBlobPlaintext({
273
+ identity: {
274
+ did: identity.did,
275
+ handle: identity.handle,
276
+ displayName: identity.displayName,
277
+ },
278
+ seeds: {
279
+ root: identity.root.seed,
280
+ public: identity.public.seed,
281
+ circle: identity.circle.seed,
282
+ self: identity.self.seed,
283
+ },
284
+ delegates: [],
285
+ });
286
+ const blobBytes = serializeBlob(plaintext);
287
+ const blobNonce = randomNonce();
288
+ const blob = encryptBlob(encKey, blobNonce, blobBytes);
289
+ let registerResp;
290
+ try {
291
+ registerResp = await registerAccount({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
292
+ email: input.email,
293
+ handle: identity.handle,
294
+ displayName: identity.displayName,
295
+ did: identity.did,
296
+ authKey,
297
+ authSalt,
298
+ encSalt,
299
+ kdf,
300
+ blob,
301
+ blobNonce,
302
+ blobVersion: 1,
303
+ });
304
+ }
305
+ finally {
306
+ zeroize(authKey);
307
+ zeroize(encKey);
308
+ }
309
+ // Hydrate in-memory state from the fresh identity.
310
+ if (this.#ownerSigners)
311
+ this.#ownerSigners.destroy();
312
+ this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
313
+ await this.#keyStore.saveOwner({
314
+ version: "0.1.0-hex",
315
+ did: identity.did,
316
+ handle: identity.handle,
317
+ displayName: identity.displayName,
318
+ seedsHex: {
319
+ root: bytesToHex(identity.root.seed),
320
+ public: bytesToHex(identity.public.seed),
321
+ circle: bytesToHex(identity.circle.seed),
322
+ self: bytesToHex(identity.self.seed),
323
+ },
324
+ savedAt: new Date().toISOString(),
325
+ });
326
+ const session = {
327
+ session: registerResp.session,
328
+ exp: registerResp.exp,
329
+ did: identity.did,
330
+ handle: identity.handle,
331
+ blob_b64: bytesToB64Public(blob),
332
+ blob_nonce_b64: bytesToB64Public(blobNonce),
333
+ blob_version: 1,
334
+ enc_key_b64: "",
335
+ is_first_login: false,
336
+ };
337
+ this.#sessionStore.set(session);
338
+ return {
339
+ session,
340
+ recoveryFile,
341
+ recoveryFilename: recoverySerialized.filename,
342
+ };
343
+ }
344
+ /* ------------------------------------------------------------------------ */
345
+ /* Recovery file */
346
+ /* ------------------------------------------------------------------------ */
51
347
  /**
52
- * Redirect the browser to Google's OAuth consent screen. Must be called
53
- * synchronously in response to a user gesture (button click) most
54
- * browsers block top-level navigation triggered from idle code.
348
+ * Sign in by uploading a recovery file. Hydrates the owner signers
349
+ * locally no JWT is obtained on this path because the recovery
350
+ * file alone doesn't authenticate against the auth backend (no
351
+ * password, no Google session). Apps that need compute/wallet
352
+ * access should follow up with an email+password sign-in or with
353
+ * Google SSO.
55
354
  *
56
- * Does not return: navigation tears the JS context down. The `never`
57
- * return type tells callers any code after the call is unreachable.
355
+ * The recovery file is ALWAYS the file produced by `signUp` (or the
356
+ * equivalent one emitted by `protocol-client`'s `runOnboarding`).
357
+ * Both shapes are accepted.
58
358
  */
359
+ async signInWithRecovery(input) {
360
+ const text = await readRecoveryFileText(input.file);
361
+ const parsed = parseRecoveryFile(text);
362
+ // Build a StoredOwnerKeys-shape on the spot, then push to keyStore +
363
+ // hydrate signers.
364
+ const stored = {
365
+ version: "0.1.0-hex",
366
+ did: parsed.did,
367
+ handle: parsed.handle,
368
+ displayName: parsed.displayName,
369
+ seedsHex: parsed.seedsHex,
370
+ savedAt: new Date().toISOString(),
371
+ };
372
+ // If a different owner is already loaded, refuse — apps must call
373
+ // signOut() first. Mixing two owners in one auth instance is a
374
+ // nonsense state we don't want to support.
375
+ if (this.#ownerSigners && this.#ownerSigners.did !== parsed.did) {
376
+ throw new AithosSDKError("auth_owner_already_loaded", "another owner is already signed in; call signOut first", { data: { current: this.#ownerSigners.did, incoming: parsed.did } });
377
+ }
378
+ if (this.#ownerSigners)
379
+ this.#ownerSigners.destroy();
380
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
381
+ await this.#keyStore.saveOwner(stored);
382
+ // Recovery flow doesn't yield a JWT — wipe any stale one to keep
383
+ // the two stores in sync.
384
+ this.#sessionStore.clear();
385
+ return {
386
+ did: parsed.did,
387
+ handle: parsed.handle,
388
+ displayName: parsed.displayName,
389
+ };
390
+ }
391
+ /* ------------------------------------------------------------------------ */
392
+ /* Mandate import */
393
+ /* ------------------------------------------------------------------------ */
394
+ /**
395
+ * Import a delegate bundle (`.aithos-delegate.json`). Works in any
396
+ * state: with no owner loaded (delegate-only session), or alongside
397
+ * an existing owner (the user holds mandates for other people's
398
+ * ethoses while also being an owner themselves).
399
+ */
400
+ async importMandate(input) {
401
+ const text = await readDelegateBundleText(input.bundle);
402
+ const parsed = parseDelegateBundle(text);
403
+ const stored = {
404
+ version: "0.1.0-hex",
405
+ subjectDid: parsed.subjectDid,
406
+ mandateId: parsed.mandateId,
407
+ mandate: parsed.mandate,
408
+ granteeId: parsed.granteeId,
409
+ granteePubkeyMultibase: parsed.granteePubkeyMultibase,
410
+ delegateSeedHex: parsed.delegateSeedHex,
411
+ importedAt: new Date().toISOString(),
412
+ };
413
+ await this.#keyStore.saveDelegate(stored);
414
+ this.#delegates.add(DelegateActor.fromStored(stored));
415
+ return {
416
+ mandateId: stored.mandateId,
417
+ subjectDid: stored.subjectDid,
418
+ granteeId: stored.granteeId,
419
+ scopes: scopesFromMandate(stored.mandate),
420
+ expiresAt: notAfterFromMandate(stored.mandate),
421
+ };
422
+ }
423
+ async removeMandate(mandateId) {
424
+ this.#delegates.remove(mandateId);
425
+ await this.#keyStore.removeDelegate(mandateId);
426
+ }
427
+ /* ------------------------------------------------------------------------ */
428
+ /* Google SSO */
429
+ /* ------------------------------------------------------------------------ */
59
430
  signInWithGoogle(opts) {
60
- if (!this.win) {
431
+ if (!this.#win) {
61
432
  throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
62
433
  }
63
434
  const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
@@ -67,47 +438,89 @@ export class AithosAuth {
67
438
  }
68
439
  url.searchParams.set("app_state", opts.appState);
69
440
  }
70
- this.win.location.assign(url.toString());
71
- // Unreachable: location.assign navigates synchronously. The throw is
72
- // belt-and-braces in case a caller awaits a microtask before unload.
441
+ this.#win.location.assign(url.toString());
73
442
  throw new AithosSDKError("auth_redirecting", "redirecting to google");
74
443
  }
75
- /**
76
- * Inspect the current URL for an `aithos_code` query parameter. If it's
77
- * present, exchange it at the backend and return the resulting
78
- * {@link AithosSession}. The query params are stripped from the URL via
79
- * `history.replaceState` so a page refresh doesn't replay the redeem
80
- * (which would 410 anyway).
81
- *
82
- * Returns `null` when there's no code in the URL — safe to call on every
83
- * page load. Throws {@link AithosSDKError} on backend errors or when
84
- * the URL carries `aithos_error=…` (Google denial, token-exchange
85
- * failure, etc.).
86
- */
87
444
  async handleCallback() {
88
- if (!this.win)
445
+ if (!this.#win)
89
446
  return null;
90
- const here = new URL(this.win.location.href);
447
+ const here = new URL(this.#win.location.href);
91
448
  const error = here.searchParams.get("aithos_error");
92
449
  const code = here.searchParams.get("aithos_code");
93
450
  const appState = here.searchParams.get("app_state");
94
451
  if (error) {
95
- cleanCallbackParams(this.win, here);
452
+ cleanCallbackParams(this.#win, here);
96
453
  throw new AithosSDKError(`auth_${error}`, `Sign-in failed: ${error}`, { data: appState ? { app_state: appState } : undefined });
97
454
  }
98
455
  if (!code)
99
456
  return null;
100
457
  const session = await this.exchange(code);
101
- cleanCallbackParams(this.win, here);
458
+ cleanCallbackParams(this.#win, here);
459
+ // Hydrate signers if the SSO response carried an enc_key (Google flow
460
+ // gives us the AES-GCM key in plaintext, encrypted only in transit
461
+ // by TLS — see auth.aithos.be design doc).
462
+ if (session.enc_key_b64 &&
463
+ session.blob_b64 &&
464
+ session.blob_nonce_b64 &&
465
+ session.blob_version > 0) {
466
+ try {
467
+ const encKey = b64ToBytes(session.enc_key_b64);
468
+ const blob = b64ToBytes(session.blob_b64);
469
+ const nonce = b64ToBytes(session.blob_nonce_b64);
470
+ try {
471
+ const blobBytes = decryptBlob(encKey, nonce, blob);
472
+ try {
473
+ const plaintext = parseBlob(blobBytes);
474
+ if (plaintext.identity.did === session.did) {
475
+ if (this.#ownerSigners)
476
+ this.#ownerSigners.destroy();
477
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
478
+ await this.#keyStore.saveOwner({
479
+ version: "0.1.0-hex",
480
+ did: plaintext.identity.did,
481
+ handle: plaintext.identity.handle,
482
+ displayName: plaintext.identity.displayName,
483
+ seedsHex: plaintext.seeds,
484
+ savedAt: new Date().toISOString(),
485
+ });
486
+ await this.#keyStore.clearAllDelegates();
487
+ this.#delegates.destroy();
488
+ for (const d of plaintext.delegates) {
489
+ const stored = storedDelegateFromBlob(d);
490
+ try {
491
+ await this.#keyStore.saveDelegate(stored);
492
+ }
493
+ catch {
494
+ /* keep going */
495
+ }
496
+ try {
497
+ this.#delegates.add(DelegateActor.fromStored(stored));
498
+ }
499
+ catch {
500
+ /* keep going */
501
+ }
502
+ }
503
+ }
504
+ }
505
+ finally {
506
+ zeroize(blobBytes);
507
+ }
508
+ }
509
+ finally {
510
+ zeroize(encKey);
511
+ }
512
+ }
513
+ catch {
514
+ // Decryption failure is non-fatal here: the JWT still works for
515
+ // compute/wallet, the user will surface the issue if they try to
516
+ // edit their ethos.
517
+ }
518
+ }
519
+ this.#sessionStore.set(session);
102
520
  return session;
103
521
  }
104
- /**
105
- * Programmatically redeem an `aithos_code` for a session. `handleCallback`
106
- * calls this for you; expose it directly for callers that already pulled
107
- * the code out of the URL via their own router.
108
- */
109
522
  async exchange(aithosCode) {
110
- const res = await this.fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
523
+ const res = await this.#fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
111
524
  method: "POST",
112
525
  headers: { "content-type": "application/json" },
113
526
  body: JSON.stringify({ aithos_code: aithosCode }),
@@ -120,7 +533,9 @@ export class AithosAuth {
120
533
  catch {
121
534
  // ignore non-JSON error body
122
535
  }
123
- const code = typeof body?.["code"] === "string" ? `auth_${body["code"]}` : "auth_exchange_failed";
536
+ const code = typeof body?.["code"] === "string"
537
+ ? `auth_${body["code"]}`
538
+ : "auth_exchange_failed";
124
539
  const message = typeof body?.["error"] === "string"
125
540
  ? body["error"]
126
541
  : `aithos_code redemption failed (${res.status})`;
@@ -131,14 +546,17 @@ export class AithosAuth {
131
546
  }
132
547
  return (await res.json());
133
548
  }
134
- /**
135
- * Stateless sign-out. The Aithos backend doesn't track sessions, so
136
- * there's nothing to revoke server-side; this method exists so the app
137
- * has a symmetric API surface and to remind callers to clear their
138
- * own storage. The Promise always resolves.
139
- */
549
+ /* ------------------------------------------------------------------------ */
550
+ /* Sign-out */
551
+ /* ------------------------------------------------------------------------ */
140
552
  async signOut() {
141
- /* no-op; sessions are stateless JWTs. */
553
+ if (this.#ownerSigners)
554
+ this.#ownerSigners.destroy();
555
+ this.#ownerSigners = null;
556
+ this.#delegates.destroy();
557
+ this.#sessionStore.clear();
558
+ await this.#keyStore.clearOwner().catch(() => { });
559
+ await this.#keyStore.clearAllDelegates().catch(() => { });
142
560
  }
143
561
  }
144
562
  /* -------------------------------------------------------------------------- */
@@ -153,4 +571,66 @@ function cleanCallbackParams(win, url) {
153
571
  url.searchParams.delete("app_state");
154
572
  win.history.replaceState(null, "", url.toString());
155
573
  }
574
+ function bytesToB64Public(bytes) {
575
+ if (bytes.length === 0)
576
+ return "";
577
+ let bin = "";
578
+ for (let i = 0; i < bytes.length; i++)
579
+ bin += String.fromCharCode(bytes[i]);
580
+ return btoa(bin).replace(/=+$/, "");
581
+ }
582
+ function b64ToBytes(b64) {
583
+ if (!b64)
584
+ return new Uint8Array(0);
585
+ // standard b64 — pad to multiple of 4 if needed
586
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
587
+ const bin = atob(b64 + pad);
588
+ const out = new Uint8Array(bin.length);
589
+ for (let i = 0; i < bin.length; i++)
590
+ out[i] = bin.charCodeAt(i);
591
+ return out;
592
+ }
593
+ function bytesToHex(b) {
594
+ let out = "";
595
+ for (let i = 0; i < b.length; i++)
596
+ out += b[i].toString(16).padStart(2, "0");
597
+ return out;
598
+ }
599
+ /**
600
+ * Project a delegate as it appears in a `BlobPlaintext` (extension-kit
601
+ * `StoredDelegate` shape) onto the SDK's own {@link StoredDelegateKeys}.
602
+ */
603
+ function storedDelegateFromBlob(d) {
604
+ return {
605
+ version: "0.1.0-hex",
606
+ subjectDid: d.subjectDid,
607
+ mandateId: d.mandateId,
608
+ mandate: d.mandate,
609
+ granteeId: d.granteeId,
610
+ granteePubkeyMultibase: d.granteePubkeyMultibase,
611
+ delegateSeedHex: d.delegateSeedHex,
612
+ importedAt: new Date().toISOString(),
613
+ };
614
+ }
615
+ function actorToInfo(a) {
616
+ return {
617
+ mandateId: a.mandateId,
618
+ subjectDid: a.subjectDid,
619
+ granteeId: a.granteeId,
620
+ scopes: scopesFromMandate(a.mandate),
621
+ expiresAt: notAfterFromMandate(a.mandate),
622
+ };
623
+ }
624
+ function scopesFromMandate(m) {
625
+ const raw = m["scopes"];
626
+ if (!Array.isArray(raw))
627
+ return [];
628
+ return raw.filter((s) => typeof s === "string");
629
+ }
630
+ function notAfterFromMandate(m) {
631
+ const raw = m["not_after"];
632
+ if (typeof raw !== "string")
633
+ return null;
634
+ return raw;
635
+ }
156
636
  //# sourceMappingURL=auth.js.map