@aithos/sdk 0.1.0-alpha.4 → 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.
- package/dist/src/auth.d.ts +94 -136
- package/dist/src/auth.js +440 -159
- package/dist/src/compute.d.ts +8 -6
- package/dist/src/compute.js +19 -11
- package/dist/src/ethos.d.ts +117 -1
- package/dist/src/ethos.js +417 -16
- package/dist/src/index.d.ts +7 -4
- package/dist/src/index.js +17 -5
- package/dist/src/internal/delegate-bundle.d.ts +18 -0
- package/dist/src/internal/delegate-bundle.js +89 -0
- package/dist/src/internal/delegate-state.d.ts +45 -0
- package/dist/src/internal/delegate-state.js +120 -0
- package/dist/src/internal/owner-signers.d.ts +78 -0
- package/dist/src/internal/owner-signers.js +179 -0
- package/dist/src/internal/protocol-client-bridge.d.ts +8 -0
- package/dist/src/internal/protocol-client-bridge.js +20 -0
- package/dist/src/internal/recovery-file.d.ts +29 -0
- package/dist/src/internal/recovery-file.js +98 -0
- package/dist/src/internal/signer.d.ts +59 -0
- package/dist/src/internal/signer.js +86 -0
- package/dist/src/key-store.d.ts +128 -0
- package/dist/src/key-store.js +244 -0
- package/dist/src/mandates.d.ts +88 -1
- package/dist/src/mandates.js +185 -8
- package/dist/src/sdk.d.ts +36 -3
- package/dist/src/sdk.js +27 -23
- package/dist/src/wallet.d.ts +4 -6
- package/dist/src/wallet.js +18 -8
- package/dist/test/auth-j3.test.d.ts +2 -0
- package/dist/test/auth-j3.test.js +360 -0
- package/dist/test/compute.test.js +22 -11
- package/dist/test/ethos.test.d.ts +2 -0
- package/dist/test/ethos.test.js +219 -0
- package/dist/test/key-store.test.d.ts +2 -0
- package/dist/test/key-store.test.js +161 -0
- package/dist/test/mandates.test.d.ts +2 -0
- package/dist/test/mandates.test.js +93 -0
- package/dist/test/sdk.test.js +64 -30
- package/dist/test/signer.test.d.ts +2 -0
- package/dist/test/signer.test.js +117 -0
- package/dist/test/wallet.test.js +20 -9
- package/package.json +2 -1
package/dist/src/auth.js
CHANGED
|
@@ -1,100 +1,238 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright 2026 Mathieu Colla
|
|
3
|
-
// Aithos auth — sign-up, sign-in
|
|
3
|
+
// Aithos auth — sign-up, sign-in (email+password / Google SSO /
|
|
4
|
+
// recovery file), mandate import, sign-out.
|
|
4
5
|
//
|
|
5
|
-
// One
|
|
6
|
-
//
|
|
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).
|
|
7
12
|
//
|
|
8
|
-
//
|
|
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).
|
|
9
19
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
// const { recoveryFile, ...session } = await auth.signUp({
|
|
15
|
-
// email, password, handle, displayName,
|
|
16
|
-
// });
|
|
17
|
-
//
|
|
18
|
-
// // Sign in with Google — redirects to Google's consent screen
|
|
19
|
-
// auth.signInWithGoogle({ appState: "/dashboard" });
|
|
20
|
-
//
|
|
21
|
-
// // Back on /auth/callback after Google
|
|
22
|
-
// const session = await auth.handleCallback();
|
|
23
|
-
//
|
|
24
|
-
// // Anywhere — read the active session, null if signed out / expired
|
|
25
|
-
// const current = auth.getCurrentSession();
|
|
26
|
-
//
|
|
27
|
-
// // Sign out — clears the session store
|
|
28
|
-
// await auth.signOut();
|
|
29
|
-
//
|
|
30
|
-
// Storage : by default, sessions are persisted in `sessionStorage` (web)
|
|
31
|
-
// or no-op (Node / SSR). Apps with different needs pass their own
|
|
32
|
-
// `AithosSessionStore` to the constructor — see ./session-store.ts.
|
|
33
|
-
import { DEFAULT_KDF, buildBlobPlaintext, createBrowserIdentity, deriveAuthAndEncKeys, encryptBlob, randomNonce, randomSalt, serializeBlob, zeroize, } from "@aithos/protocol-client";
|
|
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";
|
|
34
24
|
import { loginChallenge, loginVerify, registerAccount, } from "./auth-api.js";
|
|
35
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";
|
|
36
31
|
import { AithosSDKError } from "./types.js";
|
|
37
32
|
/** Default URL of the Aithos auth backend. */
|
|
38
33
|
export const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
|
|
39
34
|
/* -------------------------------------------------------------------------- */
|
|
40
35
|
/* AithosAuth */
|
|
41
36
|
/* -------------------------------------------------------------------------- */
|
|
42
|
-
/**
|
|
43
|
-
* Authenticator for the Aithos identity service. One instance per app
|
|
44
|
-
* is the recommended pattern (the constructor is cheap).
|
|
45
|
-
*
|
|
46
|
-
* The class is **stateful** in one specific way : it owns a session store
|
|
47
|
-
* that gets written on every successful auth call and read by
|
|
48
|
-
* {@link getCurrentSession}. Pass a custom store at construction time
|
|
49
|
-
* if you need different persistence (localStorage, IndexedDB, no-op).
|
|
50
|
-
*/
|
|
51
37
|
export class AithosAuth {
|
|
52
|
-
/** Resolved auth base URL with a trailing slash trimmed. */
|
|
53
38
|
authBaseUrl;
|
|
54
|
-
fetchImpl;
|
|
55
|
-
win;
|
|
56
|
-
|
|
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();
|
|
57
47
|
constructor(config = {}) {
|
|
58
48
|
this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
|
|
59
|
-
this
|
|
60
|
-
this
|
|
61
|
-
|
|
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();
|
|
62
55
|
}
|
|
63
56
|
/* ------------------------------------------------------------------------ */
|
|
64
|
-
/*
|
|
57
|
+
/* Boot-time hydration */
|
|
65
58
|
/* ------------------------------------------------------------------------ */
|
|
66
59
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
|
69
65
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
79
142
|
*
|
|
80
|
-
*
|
|
143
|
+
* @internal
|
|
81
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
|
+
/* ------------------------------------------------------------------------ */
|
|
82
167
|
async signIn(input) {
|
|
83
168
|
if (!input.email || !input.password) {
|
|
84
169
|
throw new AithosSDKError("auth_invalid_input", "signIn: email and password are required");
|
|
85
170
|
}
|
|
86
|
-
const challenge = await loginChallenge({ fetchImpl: this
|
|
171
|
+
const challenge = await loginChallenge({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
|
|
87
172
|
const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, challenge.authSalt, challenge.encSalt, challenge.kdf);
|
|
88
|
-
// We don't keep enc_key around for the password flow — apps that
|
|
89
|
-
// need it can call deriveAuthAndEncKeys themselves with the password
|
|
90
|
-
// still available in their UI state. Wipe to be defensive.
|
|
91
|
-
zeroize(encKey);
|
|
92
173
|
let verify;
|
|
93
174
|
try {
|
|
94
|
-
verify = await loginVerify({ fetchImpl: this
|
|
175
|
+
verify = await loginVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
|
|
95
176
|
}
|
|
96
|
-
|
|
177
|
+
catch (e) {
|
|
178
|
+
// On failure, both keys must be wiped before propagating.
|
|
97
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
|
+
}
|
|
98
236
|
}
|
|
99
237
|
const session = {
|
|
100
238
|
session: verify.session,
|
|
@@ -107,25 +245,12 @@ export class AithosAuth {
|
|
|
107
245
|
enc_key_b64: "",
|
|
108
246
|
is_first_login: false,
|
|
109
247
|
};
|
|
110
|
-
this.
|
|
248
|
+
this.#sessionStore.set(session);
|
|
111
249
|
return session;
|
|
112
250
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
* 1. Generate a fresh `BrowserIdentity` (4 Ed25519/X25519 seeds)
|
|
117
|
-
* 2. Build the recovery file (plaintext JSON, the user must save it)
|
|
118
|
-
* 3. Derive auth_key + enc_key from the password (Argon2id, fresh salts)
|
|
119
|
-
* 4. Encrypt the seeds in a vault blob (AES-GCM-256)
|
|
120
|
-
* 5. POST /auth/register with everything → JWT
|
|
121
|
-
* 6. Persist the session and return it + the recovery Blob
|
|
122
|
-
*
|
|
123
|
-
* The seeds are NOT published as an Aithos ethos here : the user's
|
|
124
|
-
* profile on `app.aithos.be` won't appear until they (or another app)
|
|
125
|
-
* publishes their first edition. This matches `aithos/app`'s design,
|
|
126
|
-
* where the vault is the source of truth for keys and the published
|
|
127
|
-
* edition is a separate concern.
|
|
128
|
-
*/
|
|
251
|
+
/* ------------------------------------------------------------------------ */
|
|
252
|
+
/* Email + password — signUp */
|
|
253
|
+
/* ------------------------------------------------------------------------ */
|
|
129
254
|
async signUp(input) {
|
|
130
255
|
if (!input.email || !input.password) {
|
|
131
256
|
throw new AithosSDKError("auth_invalid_input", "signUp: email and password are required");
|
|
@@ -134,31 +259,16 @@ export class AithosAuth {
|
|
|
134
259
|
throw new AithosSDKError("auth_invalid_handle", "signUp: handle must be 1–63 alphanumeric chars + _ -");
|
|
135
260
|
}
|
|
136
261
|
const displayName = input.displayName ?? input.handle;
|
|
137
|
-
// 1) Identity.
|
|
138
262
|
const identity = createBrowserIdentity(input.handle, displayName);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
display_name: identity.displayName,
|
|
145
|
-
did: identity.did,
|
|
146
|
-
seeds_hex: {
|
|
147
|
-
root: bytesToHex(identity.root.seed),
|
|
148
|
-
public: bytesToHex(identity.public.seed),
|
|
149
|
-
circle: bytesToHex(identity.circle.seed),
|
|
150
|
-
self: bytesToHex(identity.self.seed),
|
|
151
|
-
},
|
|
152
|
-
saved_at: new Date().toISOString(),
|
|
153
|
-
};
|
|
154
|
-
const recoveryFile = new Blob([JSON.stringify(recoveryJson, null, 2)], { type: "application/json" });
|
|
155
|
-
const recoveryFilename = `aithos-recovery-${identity.handle}.json`;
|
|
156
|
-
// 3) Derive password-based keys.
|
|
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.
|
|
157
268
|
const authSalt = randomSalt();
|
|
158
269
|
const encSalt = randomSalt();
|
|
159
270
|
const kdf = DEFAULT_KDF;
|
|
160
271
|
const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, authSalt, encSalt, kdf);
|
|
161
|
-
// 4) Build & encrypt the vault blob.
|
|
162
272
|
const plaintext = buildBlobPlaintext({
|
|
163
273
|
identity: {
|
|
164
274
|
did: identity.did,
|
|
@@ -176,10 +286,9 @@ export class AithosAuth {
|
|
|
176
286
|
const blobBytes = serializeBlob(plaintext);
|
|
177
287
|
const blobNonce = randomNonce();
|
|
178
288
|
const blob = encryptBlob(encKey, blobNonce, blobBytes);
|
|
179
|
-
// 5) Register.
|
|
180
289
|
let registerResp;
|
|
181
290
|
try {
|
|
182
|
-
registerResp = await registerAccount({ fetchImpl: this
|
|
291
|
+
registerResp = await registerAccount({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
|
|
183
292
|
email: input.email,
|
|
184
293
|
handle: identity.handle,
|
|
185
294
|
displayName: identity.displayName,
|
|
@@ -197,6 +306,23 @@ export class AithosAuth {
|
|
|
197
306
|
zeroize(authKey);
|
|
198
307
|
zeroize(encKey);
|
|
199
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
|
+
});
|
|
200
326
|
const session = {
|
|
201
327
|
session: registerResp.session,
|
|
202
328
|
exp: registerResp.exp,
|
|
@@ -208,22 +334,101 @@ export class AithosAuth {
|
|
|
208
334
|
enc_key_b64: "",
|
|
209
335
|
is_first_login: false,
|
|
210
336
|
};
|
|
211
|
-
this.
|
|
212
|
-
return {
|
|
337
|
+
this.#sessionStore.set(session);
|
|
338
|
+
return {
|
|
339
|
+
session,
|
|
340
|
+
recoveryFile,
|
|
341
|
+
recoveryFilename: recoverySerialized.filename,
|
|
342
|
+
};
|
|
213
343
|
}
|
|
214
344
|
/* ------------------------------------------------------------------------ */
|
|
215
|
-
/*
|
|
345
|
+
/* Recovery file */
|
|
216
346
|
/* ------------------------------------------------------------------------ */
|
|
217
347
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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.
|
|
221
354
|
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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.
|
|
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).
|
|
224
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
|
+
/* ------------------------------------------------------------------------ */
|
|
225
430
|
signInWithGoogle(opts) {
|
|
226
|
-
if (!this
|
|
431
|
+
if (!this.#win) {
|
|
227
432
|
throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
|
|
228
433
|
}
|
|
229
434
|
const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
|
|
@@ -233,50 +438,89 @@ export class AithosAuth {
|
|
|
233
438
|
}
|
|
234
439
|
url.searchParams.set("app_state", opts.appState);
|
|
235
440
|
}
|
|
236
|
-
this
|
|
237
|
-
// Unreachable : location.assign navigates synchronously. The throw is
|
|
238
|
-
// belt-and-braces in case a caller awaits a microtask before unload.
|
|
441
|
+
this.#win.location.assign(url.toString());
|
|
239
442
|
throw new AithosSDKError("auth_redirecting", "redirecting to google");
|
|
240
443
|
}
|
|
241
|
-
/**
|
|
242
|
-
* Inspect the current URL for an `aithos_code` query parameter. If it's
|
|
243
|
-
* present, exchange it at the backend, persist the session, and return
|
|
244
|
-
* it. The query params are stripped from the URL via
|
|
245
|
-
* `history.replaceState` so a page refresh doesn't replay the redeem
|
|
246
|
-
* (which would 410 anyway).
|
|
247
|
-
*
|
|
248
|
-
* Returns `null` when there's no code in the URL — safe to call on every
|
|
249
|
-
* page load. Throws {@link AithosSDKError} on backend errors or when
|
|
250
|
-
* the URL carries `aithos_error=…`.
|
|
251
|
-
*/
|
|
252
444
|
async handleCallback() {
|
|
253
|
-
if (!this
|
|
445
|
+
if (!this.#win)
|
|
254
446
|
return null;
|
|
255
|
-
const here = new URL(this
|
|
447
|
+
const here = new URL(this.#win.location.href);
|
|
256
448
|
const error = here.searchParams.get("aithos_error");
|
|
257
449
|
const code = here.searchParams.get("aithos_code");
|
|
258
450
|
const appState = here.searchParams.get("app_state");
|
|
259
451
|
if (error) {
|
|
260
|
-
cleanCallbackParams(this
|
|
452
|
+
cleanCallbackParams(this.#win, here);
|
|
261
453
|
throw new AithosSDKError(`auth_${error}`, `Sign-in failed: ${error}`, { data: appState ? { app_state: appState } : undefined });
|
|
262
454
|
}
|
|
263
455
|
if (!code)
|
|
264
456
|
return null;
|
|
265
457
|
const session = await this.exchange(code);
|
|
266
|
-
cleanCallbackParams(this
|
|
267
|
-
|
|
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);
|
|
268
520
|
return session;
|
|
269
521
|
}
|
|
270
|
-
/**
|
|
271
|
-
* Programmatically redeem an `aithos_code` for a session. `handleCallback`
|
|
272
|
-
* calls this for you ; expose it directly for callers that already pulled
|
|
273
|
-
* the code out of the URL via their own router.
|
|
274
|
-
*
|
|
275
|
-
* Note : this method does NOT persist the session — it's the lower-level
|
|
276
|
-
* primitive. Use `handleCallback` for the full pipe.
|
|
277
|
-
*/
|
|
278
522
|
async exchange(aithosCode) {
|
|
279
|
-
const res = await this
|
|
523
|
+
const res = await this.#fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
|
|
280
524
|
method: "POST",
|
|
281
525
|
headers: { "content-type": "application/json" },
|
|
282
526
|
body: JSON.stringify({ aithos_code: aithosCode }),
|
|
@@ -303,23 +547,16 @@ export class AithosAuth {
|
|
|
303
547
|
return (await res.json());
|
|
304
548
|
}
|
|
305
549
|
/* ------------------------------------------------------------------------ */
|
|
306
|
-
/*
|
|
550
|
+
/* Sign-out */
|
|
307
551
|
/* ------------------------------------------------------------------------ */
|
|
308
|
-
/**
|
|
309
|
-
* Read the active session from the configured store. Returns null if
|
|
310
|
-
* the user is signed out, or if the JWT has expired (the store
|
|
311
|
-
* auto-evicts expired entries — see ./session-store.ts).
|
|
312
|
-
*/
|
|
313
|
-
getCurrentSession() {
|
|
314
|
-
return this.store.get();
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Stateless sign-out — the Aithos backend doesn't track sessions, so
|
|
318
|
-
* there's nothing to revoke server-side ; this method clears the
|
|
319
|
-
* configured session store and resolves.
|
|
320
|
-
*/
|
|
321
552
|
async signOut() {
|
|
322
|
-
this
|
|
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(() => { });
|
|
323
560
|
}
|
|
324
561
|
}
|
|
325
562
|
/* -------------------------------------------------------------------------- */
|
|
@@ -334,10 +571,6 @@ function cleanCallbackParams(win, url) {
|
|
|
334
571
|
url.searchParams.delete("app_state");
|
|
335
572
|
win.history.replaceState(null, "", url.toString());
|
|
336
573
|
}
|
|
337
|
-
// Local copy of bytesToB64 — protocol-client exports it but we keep a
|
|
338
|
-
// thin local wrapper to avoid a name collision with public surface and
|
|
339
|
-
// to flag this is the "encode for the wire" path, not the "encode an
|
|
340
|
-
// auth_key" path.
|
|
341
574
|
function bytesToB64Public(bytes) {
|
|
342
575
|
if (bytes.length === 0)
|
|
343
576
|
return "";
|
|
@@ -346,10 +579,58 @@ function bytesToB64Public(bytes) {
|
|
|
346
579
|
bin += String.fromCharCode(bytes[i]);
|
|
347
580
|
return btoa(bin).replace(/=+$/, "");
|
|
348
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
|
+
}
|
|
349
593
|
function bytesToHex(b) {
|
|
350
594
|
let out = "";
|
|
351
595
|
for (let i = 0; i < b.length; i++)
|
|
352
596
|
out += b[i].toString(16).padStart(2, "0");
|
|
353
597
|
return out;
|
|
354
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
|
+
}
|
|
355
636
|
//# sourceMappingURL=auth.js.map
|