@aithos/sdk 0.1.0-alpha.2 → 0.1.0-alpha.4
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-api.d.ts +41 -0
- package/dist/src/auth-api.js +82 -0
- package/dist/src/auth.d.ts +197 -0
- package/dist/src/auth.js +355 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +11 -1
- package/dist/src/session-store.d.ts +58 -0
- package/dist/src/session-store.js +158 -0
- package/dist/test/auth.test.d.ts +2 -0
- package/dist/test/auth.test.js +175 -0
- package/package.json +3 -3
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type KdfParams } from "@aithos/protocol-client";
|
|
2
|
+
interface HttpClient {
|
|
3
|
+
readonly fetchImpl: typeof fetch;
|
|
4
|
+
readonly authBaseUrl: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RegisterApiInput {
|
|
7
|
+
readonly email: string;
|
|
8
|
+
readonly handle: string;
|
|
9
|
+
readonly displayName: string;
|
|
10
|
+
readonly did: string;
|
|
11
|
+
readonly authKey: Uint8Array;
|
|
12
|
+
readonly authSalt: Uint8Array;
|
|
13
|
+
readonly encSalt: Uint8Array;
|
|
14
|
+
readonly kdf: KdfParams;
|
|
15
|
+
readonly blob: Uint8Array;
|
|
16
|
+
readonly blobNonce: Uint8Array;
|
|
17
|
+
readonly blobVersion: number;
|
|
18
|
+
}
|
|
19
|
+
export interface RegisterApiResponse {
|
|
20
|
+
readonly session: string;
|
|
21
|
+
readonly exp: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function registerAccount(http: HttpClient, input: RegisterApiInput): Promise<RegisterApiResponse>;
|
|
24
|
+
export interface LoginChallengeResponse {
|
|
25
|
+
readonly authSalt: Uint8Array;
|
|
26
|
+
readonly encSalt: Uint8Array;
|
|
27
|
+
readonly kdf: KdfParams;
|
|
28
|
+
}
|
|
29
|
+
export declare function loginChallenge(http: HttpClient, email: string): Promise<LoginChallengeResponse>;
|
|
30
|
+
export interface LoginVerifyResponse {
|
|
31
|
+
readonly session: string;
|
|
32
|
+
readonly exp: number;
|
|
33
|
+
readonly did: string;
|
|
34
|
+
readonly handle: string;
|
|
35
|
+
readonly blob: Uint8Array;
|
|
36
|
+
readonly blobNonce: Uint8Array;
|
|
37
|
+
readonly blobVersion: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function loginVerify(http: HttpClient, email: string, authKey: Uint8Array): Promise<LoginVerifyResponse>;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=auth-api.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Thin HTTP client over the Aithos auth Lambda.
|
|
4
|
+
//
|
|
5
|
+
// Internal — not exported from the package's public surface. The
|
|
6
|
+
// {@link AithosAuth} class composes these calls with the crypto
|
|
7
|
+
// primitives in `@aithos/protocol-client` and the session store in
|
|
8
|
+
// {@link ./session-store.ts} to expose a high-level
|
|
9
|
+
// signIn / signUp / signInWithGoogle API.
|
|
10
|
+
//
|
|
11
|
+
// Wire format mirrors `aithos/auth/API.md` exactly. All `*_b64` fields
|
|
12
|
+
// are standard-base64 (not URL-safe), padding stripped — see
|
|
13
|
+
// `bytesToB64` / `b64ToBytes` in `@aithos/protocol-client`.
|
|
14
|
+
import { bytesToB64, b64ToBytes, } from "@aithos/protocol-client";
|
|
15
|
+
import { AithosSDKError } from "./types.js";
|
|
16
|
+
async function readError(res, defaultCode) {
|
|
17
|
+
let body = null;
|
|
18
|
+
try {
|
|
19
|
+
body = (await res.json());
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* body not JSON */
|
|
23
|
+
}
|
|
24
|
+
const code = typeof body?.code === "string" ? `auth_${body.code}` : `auth_${defaultCode}`;
|
|
25
|
+
const message = body?.error ?? `${res.status} ${res.statusText || "request failed"}`;
|
|
26
|
+
return new AithosSDKError(code, message, {
|
|
27
|
+
status: res.status,
|
|
28
|
+
...(body !== null ? { data: body } : {}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function postJson(http, path, body, jwt) {
|
|
32
|
+
const res = await http.fetchImpl(`${http.authBaseUrl}${path}`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
"content-type": "application/json",
|
|
36
|
+
...(jwt ? { authorization: `Bearer ${jwt}` } : {}),
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw await readError(res, "request_failed");
|
|
42
|
+
return (await res.json());
|
|
43
|
+
}
|
|
44
|
+
export async function registerAccount(http, input) {
|
|
45
|
+
return postJson(http, "/auth/register", {
|
|
46
|
+
email: input.email,
|
|
47
|
+
handle: input.handle,
|
|
48
|
+
display_name: input.displayName,
|
|
49
|
+
did: input.did,
|
|
50
|
+
auth_key_b64: bytesToB64(input.authKey),
|
|
51
|
+
auth_salt_b64: bytesToB64(input.authSalt),
|
|
52
|
+
enc_salt_b64: bytesToB64(input.encSalt),
|
|
53
|
+
kdf: input.kdf,
|
|
54
|
+
blob_b64: bytesToB64(input.blob),
|
|
55
|
+
blob_nonce_b64: bytesToB64(input.blobNonce),
|
|
56
|
+
blob_version: input.blobVersion,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export async function loginChallenge(http, email) {
|
|
60
|
+
const wire = await postJson(http, "/auth/login/challenge", { email });
|
|
61
|
+
return {
|
|
62
|
+
authSalt: b64ToBytes(wire.auth_salt_b64),
|
|
63
|
+
encSalt: b64ToBytes(wire.enc_salt_b64),
|
|
64
|
+
kdf: wire.kdf,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function loginVerify(http, email, authKey) {
|
|
68
|
+
const wire = await postJson(http, "/auth/login/verify", {
|
|
69
|
+
email,
|
|
70
|
+
auth_key_b64: bytesToB64(authKey),
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
session: wire.session,
|
|
74
|
+
exp: wire.exp,
|
|
75
|
+
did: wire.did,
|
|
76
|
+
handle: wire.handle,
|
|
77
|
+
blob: b64ToBytes(wire.blob_b64),
|
|
78
|
+
blobNonce: b64ToBytes(wire.blob_nonce_b64),
|
|
79
|
+
blobVersion: wire.blob_version,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=auth-api.js.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { type AithosSessionStore } from "./session-store.js";
|
|
2
|
+
/** Default URL of the Aithos auth backend. */
|
|
3
|
+
export declare const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
|
|
4
|
+
/**
|
|
5
|
+
* Construction options for {@link AithosAuth}.
|
|
6
|
+
*/
|
|
7
|
+
export interface AithosAuthConfig {
|
|
8
|
+
/**
|
|
9
|
+
* Base URL of the Aithos auth backend. Defaults to
|
|
10
|
+
* {@link DEFAULT_AUTH_BASE_URL}. Override for staging or self-hosted
|
|
11
|
+
* deployments.
|
|
12
|
+
*/
|
|
13
|
+
readonly authBaseUrl?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Optional `fetch` implementation. Defaults to `globalThis.fetch`. Used
|
|
16
|
+
* by tests to inject a mock without monkeypatching globals.
|
|
17
|
+
*/
|
|
18
|
+
readonly fetch?: typeof fetch;
|
|
19
|
+
/**
|
|
20
|
+
* Optional `window`-like object. Defaults to `globalThis.window` when
|
|
21
|
+
* available. Provided so node-side tests can assert redirect URLs without
|
|
22
|
+
* shimming jsdom.
|
|
23
|
+
*/
|
|
24
|
+
readonly window?: Pick<Window, "location" | "history">;
|
|
25
|
+
/**
|
|
26
|
+
* Pluggable session storage. Defaults to {@link sessionStorageStore}
|
|
27
|
+
* in browser environments, {@link noopStore} elsewhere. See
|
|
28
|
+
* `./session-store.ts` for built-in alternatives or to roll your own.
|
|
29
|
+
*/
|
|
30
|
+
readonly sessionStore?: AithosSessionStore;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Active Aithos session. Persisted in the configured session store and
|
|
34
|
+
* surfaced by {@link AithosAuth.getCurrentSession}.
|
|
35
|
+
*
|
|
36
|
+
* Wire-compatible with the auth Lambda's `SsoExchangeResponse` and
|
|
37
|
+
* register/verify payloads ; field names are kept snake_case to match
|
|
38
|
+
* the backend.
|
|
39
|
+
*/
|
|
40
|
+
export interface AithosSession {
|
|
41
|
+
/** HS256 JWT — send in `Authorization: Bearer <session>` to auth/* and
|
|
42
|
+
* app endpoints that consume it. */
|
|
43
|
+
readonly session: string;
|
|
44
|
+
/** JWT expiry, Unix seconds. */
|
|
45
|
+
readonly exp: number;
|
|
46
|
+
/** Aithos DID — `did:aithos:z…`. Stable across all the user's devices. */
|
|
47
|
+
readonly did: string;
|
|
48
|
+
/** User-visible handle (rendered as `@handle`). */
|
|
49
|
+
readonly handle: string;
|
|
50
|
+
/** Encrypted vault blob, base64. Empty string on first Google sign-in. */
|
|
51
|
+
readonly blob_b64: string;
|
|
52
|
+
/** AES-GCM nonce for the blob, base64 (12 bytes). Empty on first Google sign-in. */
|
|
53
|
+
readonly blob_nonce_b64: string;
|
|
54
|
+
/** Monotonic blob version. 0 on first Google sign-in. */
|
|
55
|
+
readonly blob_version: number;
|
|
56
|
+
/** 32-byte vault key, base64. Decrypts {@link blob_b64} via AES-GCM-256.
|
|
57
|
+
* Returned by Google SSO ; absent (empty string) for password sign-in
|
|
58
|
+
* where the key stays in browser memory only. */
|
|
59
|
+
readonly enc_key_b64: string;
|
|
60
|
+
/** True the first time this user signs in (Google flow only). */
|
|
61
|
+
readonly is_first_login: boolean;
|
|
62
|
+
}
|
|
63
|
+
/** Options for {@link AithosAuth.signInWithGoogle}. */
|
|
64
|
+
export interface SignInWithGoogleOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Opaque deep-link state preserved across the OAuth round-trip and
|
|
67
|
+
* surfaced back to the app via `?app_state=…` on the callback URL. Use
|
|
68
|
+
* to remember "the user clicked sign-in from /settings/billing" so you
|
|
69
|
+
* can restore that route after the redirect chain.
|
|
70
|
+
*
|
|
71
|
+
* Maximum 1024 characters.
|
|
72
|
+
*/
|
|
73
|
+
readonly appState?: string;
|
|
74
|
+
}
|
|
75
|
+
/** Options for {@link AithosAuth.signIn}. */
|
|
76
|
+
export interface SignInInput {
|
|
77
|
+
readonly email: string;
|
|
78
|
+
readonly password: string;
|
|
79
|
+
}
|
|
80
|
+
/** Options for {@link AithosAuth.signUp}. */
|
|
81
|
+
export interface SignUpInput {
|
|
82
|
+
readonly email: string;
|
|
83
|
+
readonly password: string;
|
|
84
|
+
/** Aithos handle (the @-name). 1–63 alphanumeric chars + `_`/`-`. */
|
|
85
|
+
readonly handle: string;
|
|
86
|
+
/** Optional human-readable name. Defaults to the handle. */
|
|
87
|
+
readonly displayName?: string;
|
|
88
|
+
}
|
|
89
|
+
/** Result of {@link AithosAuth.signUp}. */
|
|
90
|
+
export interface SignUpResult {
|
|
91
|
+
readonly session: AithosSession;
|
|
92
|
+
/**
|
|
93
|
+
* Recovery file containing the user's seed material, plaintext at the
|
|
94
|
+
* V1 spec ; the app should offer this to the user as a download. Lose
|
|
95
|
+
* this file AND forget the password = lose the ethos.
|
|
96
|
+
*
|
|
97
|
+
* Format : JSON, content-type `application/json`. Filename suggestion
|
|
98
|
+
* exposed as {@link recoveryFilename} for direct use with `<a download>`.
|
|
99
|
+
*/
|
|
100
|
+
readonly recoveryFile: Blob;
|
|
101
|
+
/** Suggested filename for {@link recoveryFile}. */
|
|
102
|
+
readonly recoveryFilename: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Authenticator for the Aithos identity service. One instance per app
|
|
106
|
+
* is the recommended pattern (the constructor is cheap).
|
|
107
|
+
*
|
|
108
|
+
* The class is **stateful** in one specific way : it owns a session store
|
|
109
|
+
* that gets written on every successful auth call and read by
|
|
110
|
+
* {@link getCurrentSession}. Pass a custom store at construction time
|
|
111
|
+
* if you need different persistence (localStorage, IndexedDB, no-op).
|
|
112
|
+
*/
|
|
113
|
+
export declare class AithosAuth {
|
|
114
|
+
/** Resolved auth base URL with a trailing slash trimmed. */
|
|
115
|
+
readonly authBaseUrl: string;
|
|
116
|
+
private readonly fetchImpl;
|
|
117
|
+
private readonly win;
|
|
118
|
+
private readonly store;
|
|
119
|
+
constructor(config?: AithosAuthConfig);
|
|
120
|
+
/**
|
|
121
|
+
* Sign in to an existing Aithos account. Two-round-trip flow under the
|
|
122
|
+
* hood :
|
|
123
|
+
*
|
|
124
|
+
* 1. POST /auth/login/challenge → server returns the salts + KDF params
|
|
125
|
+
* 2. derive auth_key from password + salt (Argon2id)
|
|
126
|
+
* 3. POST /auth/login/verify → server checks auth_key, returns JWT + blob
|
|
127
|
+
*
|
|
128
|
+
* The returned `enc_key_b64` is empty by design : password sign-in
|
|
129
|
+
* doesn't release the vault key over the wire (it's derived locally
|
|
130
|
+
* but discarded after the call). Apps that need the seeds — most
|
|
131
|
+
* apps don't — should use the (forthcoming) `loadEthos` helper
|
|
132
|
+
* separately with the password still in hand.
|
|
133
|
+
*
|
|
134
|
+
* Persists the session in the configured store before returning.
|
|
135
|
+
*/
|
|
136
|
+
signIn(input: SignInInput): Promise<AithosSession>;
|
|
137
|
+
/**
|
|
138
|
+
* Create a new Aithos account end-to-end :
|
|
139
|
+
*
|
|
140
|
+
* 1. Generate a fresh `BrowserIdentity` (4 Ed25519/X25519 seeds)
|
|
141
|
+
* 2. Build the recovery file (plaintext JSON, the user must save it)
|
|
142
|
+
* 3. Derive auth_key + enc_key from the password (Argon2id, fresh salts)
|
|
143
|
+
* 4. Encrypt the seeds in a vault blob (AES-GCM-256)
|
|
144
|
+
* 5. POST /auth/register with everything → JWT
|
|
145
|
+
* 6. Persist the session and return it + the recovery Blob
|
|
146
|
+
*
|
|
147
|
+
* The seeds are NOT published as an Aithos ethos here : the user's
|
|
148
|
+
* profile on `app.aithos.be` won't appear until they (or another app)
|
|
149
|
+
* publishes their first edition. This matches `aithos/app`'s design,
|
|
150
|
+
* where the vault is the source of truth for keys and the published
|
|
151
|
+
* edition is a separate concern.
|
|
152
|
+
*/
|
|
153
|
+
signUp(input: SignUpInput): Promise<SignUpResult>;
|
|
154
|
+
/**
|
|
155
|
+
* Redirect the browser to Google's OAuth consent screen. Must be called
|
|
156
|
+
* synchronously in response to a user gesture (button click) — most
|
|
157
|
+
* browsers block top-level navigation triggered from idle code.
|
|
158
|
+
*
|
|
159
|
+
* Does not return : navigation tears the JS context down. The `never`
|
|
160
|
+
* return type tells callers any code after the call is unreachable.
|
|
161
|
+
*/
|
|
162
|
+
signInWithGoogle(opts?: SignInWithGoogleOptions): never;
|
|
163
|
+
/**
|
|
164
|
+
* Inspect the current URL for an `aithos_code` query parameter. If it's
|
|
165
|
+
* present, exchange it at the backend, persist the session, and return
|
|
166
|
+
* it. The query params are stripped from the URL via
|
|
167
|
+
* `history.replaceState` so a page refresh doesn't replay the redeem
|
|
168
|
+
* (which would 410 anyway).
|
|
169
|
+
*
|
|
170
|
+
* Returns `null` when there's no code in the URL — safe to call on every
|
|
171
|
+
* page load. Throws {@link AithosSDKError} on backend errors or when
|
|
172
|
+
* the URL carries `aithos_error=…`.
|
|
173
|
+
*/
|
|
174
|
+
handleCallback(): Promise<AithosSession | null>;
|
|
175
|
+
/**
|
|
176
|
+
* Programmatically redeem an `aithos_code` for a session. `handleCallback`
|
|
177
|
+
* calls this for you ; expose it directly for callers that already pulled
|
|
178
|
+
* the code out of the URL via their own router.
|
|
179
|
+
*
|
|
180
|
+
* Note : this method does NOT persist the session — it's the lower-level
|
|
181
|
+
* primitive. Use `handleCallback` for the full pipe.
|
|
182
|
+
*/
|
|
183
|
+
exchange(aithosCode: string): Promise<AithosSession>;
|
|
184
|
+
/**
|
|
185
|
+
* Read the active session from the configured store. Returns null if
|
|
186
|
+
* the user is signed out, or if the JWT has expired (the store
|
|
187
|
+
* auto-evicts expired entries — see ./session-store.ts).
|
|
188
|
+
*/
|
|
189
|
+
getCurrentSession(): AithosSession | null;
|
|
190
|
+
/**
|
|
191
|
+
* Stateless sign-out — the Aithos backend doesn't track sessions, so
|
|
192
|
+
* there's nothing to revoke server-side ; this method clears the
|
|
193
|
+
* configured session store and resolves.
|
|
194
|
+
*/
|
|
195
|
+
signOut(): Promise<void>;
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=auth.d.ts.map
|
package/dist/src/auth.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Aithos auth — sign-up, sign-in, sign-in-with-Google.
|
|
4
|
+
//
|
|
5
|
+
// One class, three flows, automatic session persistence. Apps shouldn't
|
|
6
|
+
// need to touch Argon2id, AES-GCM, or {@link sessionStorage} directly :
|
|
7
|
+
//
|
|
8
|
+
// const auth = new AithosAuth();
|
|
9
|
+
//
|
|
10
|
+
// // Sign in (existing account, email + password)
|
|
11
|
+
// const session = await auth.signIn({ email, password });
|
|
12
|
+
//
|
|
13
|
+
// // Sign up (creates an Aithos identity end-to-end)
|
|
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";
|
|
34
|
+
import { loginChallenge, loginVerify, registerAccount, } from "./auth-api.js";
|
|
35
|
+
import { defaultSessionStore, } from "./session-store.js";
|
|
36
|
+
import { AithosSDKError } from "./types.js";
|
|
37
|
+
/** Default URL of the Aithos auth backend. */
|
|
38
|
+
export const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
|
|
39
|
+
/* -------------------------------------------------------------------------- */
|
|
40
|
+
/* AithosAuth */
|
|
41
|
+
/* -------------------------------------------------------------------------- */
|
|
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
|
+
export class AithosAuth {
|
|
52
|
+
/** Resolved auth base URL with a trailing slash trimmed. */
|
|
53
|
+
authBaseUrl;
|
|
54
|
+
fetchImpl;
|
|
55
|
+
win;
|
|
56
|
+
store;
|
|
57
|
+
constructor(config = {}) {
|
|
58
|
+
this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
|
|
59
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
60
|
+
this.win = config.window ?? (typeof window !== "undefined" ? window : undefined);
|
|
61
|
+
this.store = config.sessionStore ?? defaultSessionStore();
|
|
62
|
+
}
|
|
63
|
+
/* ------------------------------------------------------------------------ */
|
|
64
|
+
/* Email + password */
|
|
65
|
+
/* ------------------------------------------------------------------------ */
|
|
66
|
+
/**
|
|
67
|
+
* Sign in to an existing Aithos account. Two-round-trip flow under the
|
|
68
|
+
* hood :
|
|
69
|
+
*
|
|
70
|
+
* 1. POST /auth/login/challenge → server returns the salts + KDF params
|
|
71
|
+
* 2. derive auth_key from password + salt (Argon2id)
|
|
72
|
+
* 3. POST /auth/login/verify → server checks auth_key, returns JWT + blob
|
|
73
|
+
*
|
|
74
|
+
* The returned `enc_key_b64` is empty by design : password sign-in
|
|
75
|
+
* doesn't release the vault key over the wire (it's derived locally
|
|
76
|
+
* but discarded after the call). Apps that need the seeds — most
|
|
77
|
+
* apps don't — should use the (forthcoming) `loadEthos` helper
|
|
78
|
+
* separately with the password still in hand.
|
|
79
|
+
*
|
|
80
|
+
* Persists the session in the configured store before returning.
|
|
81
|
+
*/
|
|
82
|
+
async signIn(input) {
|
|
83
|
+
if (!input.email || !input.password) {
|
|
84
|
+
throw new AithosSDKError("auth_invalid_input", "signIn: email and password are required");
|
|
85
|
+
}
|
|
86
|
+
const challenge = await loginChallenge({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
|
|
87
|
+
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
|
+
let verify;
|
|
93
|
+
try {
|
|
94
|
+
verify = await loginVerify({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
zeroize(authKey);
|
|
98
|
+
}
|
|
99
|
+
const session = {
|
|
100
|
+
session: verify.session,
|
|
101
|
+
exp: verify.exp,
|
|
102
|
+
did: verify.did,
|
|
103
|
+
handle: verify.handle,
|
|
104
|
+
blob_b64: bytesToB64Public(verify.blob),
|
|
105
|
+
blob_nonce_b64: bytesToB64Public(verify.blobNonce),
|
|
106
|
+
blob_version: verify.blobVersion,
|
|
107
|
+
enc_key_b64: "",
|
|
108
|
+
is_first_login: false,
|
|
109
|
+
};
|
|
110
|
+
this.store.set(session);
|
|
111
|
+
return session;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Create a new Aithos account end-to-end :
|
|
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
|
+
*/
|
|
129
|
+
async signUp(input) {
|
|
130
|
+
if (!input.email || !input.password) {
|
|
131
|
+
throw new AithosSDKError("auth_invalid_input", "signUp: email and password are required");
|
|
132
|
+
}
|
|
133
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(input.handle)) {
|
|
134
|
+
throw new AithosSDKError("auth_invalid_handle", "signUp: handle must be 1–63 alphanumeric chars + _ -");
|
|
135
|
+
}
|
|
136
|
+
const displayName = input.displayName ?? input.handle;
|
|
137
|
+
// 1) Identity.
|
|
138
|
+
const identity = createBrowserIdentity(input.handle, displayName);
|
|
139
|
+
// 2) Recovery file — same shape as @aithos/protocol-client emits in
|
|
140
|
+
// `runOnboarding`, plaintext v0.1.0.
|
|
141
|
+
const recoveryJson = {
|
|
142
|
+
aithos_recovery_version: "0.1.0-plaintext",
|
|
143
|
+
handle: identity.handle,
|
|
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.
|
|
157
|
+
const authSalt = randomSalt();
|
|
158
|
+
const encSalt = randomSalt();
|
|
159
|
+
const kdf = DEFAULT_KDF;
|
|
160
|
+
const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, authSalt, encSalt, kdf);
|
|
161
|
+
// 4) Build & encrypt the vault blob.
|
|
162
|
+
const plaintext = buildBlobPlaintext({
|
|
163
|
+
identity: {
|
|
164
|
+
did: identity.did,
|
|
165
|
+
handle: identity.handle,
|
|
166
|
+
displayName: identity.displayName,
|
|
167
|
+
},
|
|
168
|
+
seeds: {
|
|
169
|
+
root: identity.root.seed,
|
|
170
|
+
public: identity.public.seed,
|
|
171
|
+
circle: identity.circle.seed,
|
|
172
|
+
self: identity.self.seed,
|
|
173
|
+
},
|
|
174
|
+
delegates: [],
|
|
175
|
+
});
|
|
176
|
+
const blobBytes = serializeBlob(plaintext);
|
|
177
|
+
const blobNonce = randomNonce();
|
|
178
|
+
const blob = encryptBlob(encKey, blobNonce, blobBytes);
|
|
179
|
+
// 5) Register.
|
|
180
|
+
let registerResp;
|
|
181
|
+
try {
|
|
182
|
+
registerResp = await registerAccount({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, {
|
|
183
|
+
email: input.email,
|
|
184
|
+
handle: identity.handle,
|
|
185
|
+
displayName: identity.displayName,
|
|
186
|
+
did: identity.did,
|
|
187
|
+
authKey,
|
|
188
|
+
authSalt,
|
|
189
|
+
encSalt,
|
|
190
|
+
kdf,
|
|
191
|
+
blob,
|
|
192
|
+
blobNonce,
|
|
193
|
+
blobVersion: 1,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
zeroize(authKey);
|
|
198
|
+
zeroize(encKey);
|
|
199
|
+
}
|
|
200
|
+
const session = {
|
|
201
|
+
session: registerResp.session,
|
|
202
|
+
exp: registerResp.exp,
|
|
203
|
+
did: identity.did,
|
|
204
|
+
handle: identity.handle,
|
|
205
|
+
blob_b64: bytesToB64Public(blob),
|
|
206
|
+
blob_nonce_b64: bytesToB64Public(blobNonce),
|
|
207
|
+
blob_version: 1,
|
|
208
|
+
enc_key_b64: "",
|
|
209
|
+
is_first_login: false,
|
|
210
|
+
};
|
|
211
|
+
this.store.set(session);
|
|
212
|
+
return { session, recoveryFile, recoveryFilename };
|
|
213
|
+
}
|
|
214
|
+
/* ------------------------------------------------------------------------ */
|
|
215
|
+
/* Google SSO */
|
|
216
|
+
/* ------------------------------------------------------------------------ */
|
|
217
|
+
/**
|
|
218
|
+
* Redirect the browser to Google's OAuth consent screen. Must be called
|
|
219
|
+
* synchronously in response to a user gesture (button click) — most
|
|
220
|
+
* browsers block top-level navigation triggered from idle code.
|
|
221
|
+
*
|
|
222
|
+
* Does not return : navigation tears the JS context down. The `never`
|
|
223
|
+
* return type tells callers any code after the call is unreachable.
|
|
224
|
+
*/
|
|
225
|
+
signInWithGoogle(opts) {
|
|
226
|
+
if (!this.win) {
|
|
227
|
+
throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
|
|
228
|
+
}
|
|
229
|
+
const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
|
|
230
|
+
if (opts?.appState) {
|
|
231
|
+
if (opts.appState.length > 1024) {
|
|
232
|
+
throw new AithosSDKError("auth_app_state_too_long", "appState must be ≤ 1024 chars");
|
|
233
|
+
}
|
|
234
|
+
url.searchParams.set("app_state", opts.appState);
|
|
235
|
+
}
|
|
236
|
+
this.win.location.assign(url.toString());
|
|
237
|
+
// Unreachable : location.assign navigates synchronously. The throw is
|
|
238
|
+
// belt-and-braces in case a caller awaits a microtask before unload.
|
|
239
|
+
throw new AithosSDKError("auth_redirecting", "redirecting to google");
|
|
240
|
+
}
|
|
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
|
+
async handleCallback() {
|
|
253
|
+
if (!this.win)
|
|
254
|
+
return null;
|
|
255
|
+
const here = new URL(this.win.location.href);
|
|
256
|
+
const error = here.searchParams.get("aithos_error");
|
|
257
|
+
const code = here.searchParams.get("aithos_code");
|
|
258
|
+
const appState = here.searchParams.get("app_state");
|
|
259
|
+
if (error) {
|
|
260
|
+
cleanCallbackParams(this.win, here);
|
|
261
|
+
throw new AithosSDKError(`auth_${error}`, `Sign-in failed: ${error}`, { data: appState ? { app_state: appState } : undefined });
|
|
262
|
+
}
|
|
263
|
+
if (!code)
|
|
264
|
+
return null;
|
|
265
|
+
const session = await this.exchange(code);
|
|
266
|
+
cleanCallbackParams(this.win, here);
|
|
267
|
+
this.store.set(session);
|
|
268
|
+
return session;
|
|
269
|
+
}
|
|
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
|
+
async exchange(aithosCode) {
|
|
279
|
+
const res = await this.fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: { "content-type": "application/json" },
|
|
282
|
+
body: JSON.stringify({ aithos_code: aithosCode }),
|
|
283
|
+
});
|
|
284
|
+
if (!res.ok) {
|
|
285
|
+
let body;
|
|
286
|
+
try {
|
|
287
|
+
body = (await res.json());
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// ignore non-JSON error body
|
|
291
|
+
}
|
|
292
|
+
const code = typeof body?.["code"] === "string"
|
|
293
|
+
? `auth_${body["code"]}`
|
|
294
|
+
: "auth_exchange_failed";
|
|
295
|
+
const message = typeof body?.["error"] === "string"
|
|
296
|
+
? body["error"]
|
|
297
|
+
: `aithos_code redemption failed (${res.status})`;
|
|
298
|
+
throw new AithosSDKError(code, message, {
|
|
299
|
+
status: res.status,
|
|
300
|
+
...(body !== undefined ? { data: body } : {}),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return (await res.json());
|
|
304
|
+
}
|
|
305
|
+
/* ------------------------------------------------------------------------ */
|
|
306
|
+
/* Session lifecycle */
|
|
307
|
+
/* ------------------------------------------------------------------------ */
|
|
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
|
+
async signOut() {
|
|
322
|
+
this.store.clear();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/* -------------------------------------------------------------------------- */
|
|
326
|
+
/* Helpers */
|
|
327
|
+
/* -------------------------------------------------------------------------- */
|
|
328
|
+
function trimSlash(url) {
|
|
329
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
330
|
+
}
|
|
331
|
+
function cleanCallbackParams(win, url) {
|
|
332
|
+
url.searchParams.delete("aithos_code");
|
|
333
|
+
url.searchParams.delete("aithos_error");
|
|
334
|
+
url.searchParams.delete("app_state");
|
|
335
|
+
win.history.replaceState(null, "", url.toString());
|
|
336
|
+
}
|
|
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
|
+
function bytesToB64Public(bytes) {
|
|
342
|
+
if (bytes.length === 0)
|
|
343
|
+
return "";
|
|
344
|
+
let bin = "";
|
|
345
|
+
for (let i = 0; i < bytes.length; i++)
|
|
346
|
+
bin += String.fromCharCode(bytes[i]);
|
|
347
|
+
return btoa(bin).replace(/=+$/, "");
|
|
348
|
+
}
|
|
349
|
+
function bytesToHex(b) {
|
|
350
|
+
let out = "";
|
|
351
|
+
for (let i = 0; i < b.length; i++)
|
|
352
|
+
out += b[i].toString(16).padStart(2, "0");
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
//# sourceMappingURL=auth.js.map
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.0-alpha.
|
|
1
|
+
export declare const VERSION = "0.1.0-alpha.4";
|
|
2
2
|
export { AithosSDK } from "./sdk.js";
|
|
3
3
|
export type { AithosSDKConfig } from "./types.js";
|
|
4
4
|
export { AithosSDKError } from "./types.js";
|
|
@@ -8,6 +8,9 @@ export type { ComputeMessage, InvokeBedrockArgs, InvokeBedrockResult, StopReason
|
|
|
8
8
|
export { ComputeNamespace } from "./compute.js";
|
|
9
9
|
export type { CreditPackId, CreateTopupSessionArgs, CreateTopupSessionResult, GetBalanceArgs, GetBalanceResult, } from "./wallet.js";
|
|
10
10
|
export { WalletNamespace } from "./wallet.js";
|
|
11
|
+
export { AithosAuth, DEFAULT_AUTH_BASE_URL } from "./auth.js";
|
|
12
|
+
export type { AithosAuthConfig, AithosSession, SignInInput, SignInWithGoogleOptions, SignUpInput, SignUpResult, } from "./auth.js";
|
|
13
|
+
export { DEFAULT_SESSION_STORAGE_KEY, defaultSessionStore, localStorageStore, noopStore, sessionStorageStore, type AithosSessionStore, } from "./session-store.js";
|
|
11
14
|
export * as ethos from "./ethos.js";
|
|
12
15
|
export * as onboarding from "./onboarding.js";
|
|
13
16
|
export * as mandates from "./mandates.js";
|
package/dist/src/index.js
CHANGED
|
@@ -17,12 +17,22 @@
|
|
|
17
17
|
// Public types specific to the SDK (`AithosSDKConfig`, `AithosSDKError`)
|
|
18
18
|
// are exported from here. Endpoint config (`AithosSdkEndpoints`,
|
|
19
19
|
// `DEFAULT_SDK_ENDPOINTS`) likewise.
|
|
20
|
-
export const VERSION = "0.1.0-alpha.
|
|
20
|
+
export const VERSION = "0.1.0-alpha.4";
|
|
21
21
|
export { AithosSDK } from "./sdk.js";
|
|
22
22
|
export { AithosSDKError } from "./types.js";
|
|
23
23
|
export { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
|
|
24
24
|
export { ComputeNamespace } from "./compute.js";
|
|
25
25
|
export { WalletNamespace } from "./wallet.js";
|
|
26
|
+
// Sign-up, sign-in, sign-in-with-Google. Lives outside the AithosSDK
|
|
27
|
+
// class because the auth flow runs *before* the user has a
|
|
28
|
+
// BrowserIdentity (sign-up creates one, sign-in restores it from the
|
|
29
|
+
// server). The class also owns the session store — see
|
|
30
|
+
// ./session-store.ts for pluggable persistence.
|
|
31
|
+
export { AithosAuth, DEFAULT_AUTH_BASE_URL } from "./auth.js";
|
|
32
|
+
// Session storage backends used by AithosAuth. `sessionStorageStore` is
|
|
33
|
+
// the default in browser environments ; pass another store at construction
|
|
34
|
+
// time if you need different persistence.
|
|
35
|
+
export { DEFAULT_SESSION_STORAGE_KEY, defaultSessionStore, localStorageStore, noopStore, sessionStorageStore, } from "./session-store.js";
|
|
26
36
|
// Re-exports under stable namespace modules. Apps may also import these
|
|
27
37
|
// directly via `@aithos/protocol-client`; the SDK simply curates them.
|
|
28
38
|
export * as ethos from "./ethos.js";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AithosSession } from "./auth.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pluggable storage backend for the active session.
|
|
4
|
+
*
|
|
5
|
+
* Implementations should be **synchronous** : the SDK reads the store on
|
|
6
|
+
* boot to surface `getCurrentSession()` and async storage would force a
|
|
7
|
+
* Promise everywhere it's not warranted. Async backends should pre-warm
|
|
8
|
+
* the cache during the app's bootstrap and then implement the methods
|
|
9
|
+
* over that cache.
|
|
10
|
+
*
|
|
11
|
+
* `set` is called after every successful sign-in / sign-up / Google
|
|
12
|
+
* callback exchange. `clear` is called on `signOut`. `get` is called by
|
|
13
|
+
* `getCurrentSession()` and may also be called by the app on boot.
|
|
14
|
+
*/
|
|
15
|
+
export interface AithosSessionStore {
|
|
16
|
+
/** Read the persisted session. Return null if none, or if it's expired. */
|
|
17
|
+
get(): AithosSession | null;
|
|
18
|
+
/** Persist the session. Implementations should not throw — at worst
|
|
19
|
+
* they should warn and silently no-op (e.g. quota exceeded). */
|
|
20
|
+
set(session: AithosSession): void;
|
|
21
|
+
/** Wipe the persisted session. */
|
|
22
|
+
clear(): void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Storage key used by the bundled stores. Apps that want to coexist with
|
|
26
|
+
* other Aithos-aware libs (or that want to scope sessions per-tenant) can
|
|
27
|
+
* pass a custom key via {@link sessionStorageStore} or
|
|
28
|
+
* {@link localStorageStore}.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_SESSION_STORAGE_KEY = "aithos.session.v1";
|
|
31
|
+
interface WebStorageStoreOptions {
|
|
32
|
+
/** Storage key. Defaults to {@link DEFAULT_SESSION_STORAGE_KEY}. */
|
|
33
|
+
readonly key?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Default web store : `sessionStorage`. The session lives until the tab
|
|
37
|
+
* is closed. Cleared on `signOut()`. Use this when reauthenticating each
|
|
38
|
+
* day is acceptable and reduces blast radius after an XSS.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sessionStorageStore(opts?: WebStorageStoreOptions): AithosSessionStore;
|
|
41
|
+
/**
|
|
42
|
+
* `localStorage` store. The session persists until the JWT expires or the
|
|
43
|
+
* user explicitly signs out. Higher convenience, larger XSS blast radius.
|
|
44
|
+
*/
|
|
45
|
+
export declare function localStorageStore(opts?: WebStorageStoreOptions): AithosSessionStore;
|
|
46
|
+
/**
|
|
47
|
+
* No-op store. `set` and `clear` discard their input ; `get` always
|
|
48
|
+
* returns null. The default in non-browser contexts (Node, edge runtimes)
|
|
49
|
+
* — apps running there should pass their own store explicitly.
|
|
50
|
+
*/
|
|
51
|
+
export declare function noopStore(): AithosSessionStore;
|
|
52
|
+
/**
|
|
53
|
+
* Pick a sensible default : `sessionStorage` if the browser environment
|
|
54
|
+
* is available, {@link noopStore} otherwise.
|
|
55
|
+
*/
|
|
56
|
+
export declare function defaultSessionStore(): AithosSessionStore;
|
|
57
|
+
export {};
|
|
58
|
+
//# sourceMappingURL=session-store.d.ts.map
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/* -------------------------------------------------------------------------- */
|
|
4
|
+
/* Storage key & expiration */
|
|
5
|
+
/* -------------------------------------------------------------------------- */
|
|
6
|
+
/**
|
|
7
|
+
* Storage key used by the bundled stores. Apps that want to coexist with
|
|
8
|
+
* other Aithos-aware libs (or that want to scope sessions per-tenant) can
|
|
9
|
+
* pass a custom key via {@link sessionStorageStore} or
|
|
10
|
+
* {@link localStorageStore}.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_SESSION_STORAGE_KEY = "aithos.session.v1";
|
|
13
|
+
/** Conservative buffer — drop the session 30 s before its `exp` so we
|
|
14
|
+
* don't hand out a token the server is about to reject. */
|
|
15
|
+
const SESSION_EXPIRY_BUFFER_S = 30;
|
|
16
|
+
function isExpired(session, nowSec) {
|
|
17
|
+
return session.exp <= nowSec + SESSION_EXPIRY_BUFFER_S;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validate at runtime that an opaque object looks like an `AithosSession`.
|
|
21
|
+
* Storage values come from JSON.parse over user-controlled data — we can't
|
|
22
|
+
* trust them blind. This isn't a security check (the server validates the
|
|
23
|
+
* JWT ; persistence layers don't authenticate themselves) ; it just
|
|
24
|
+
* prevents weird crashes when the storage was tampered with.
|
|
25
|
+
*/
|
|
26
|
+
function isSessionShaped(v) {
|
|
27
|
+
if (typeof v !== "object" || v === null)
|
|
28
|
+
return false;
|
|
29
|
+
const o = v;
|
|
30
|
+
return (typeof o["session"] === "string" &&
|
|
31
|
+
typeof o["exp"] === "number" &&
|
|
32
|
+
typeof o["did"] === "string" &&
|
|
33
|
+
typeof o["handle"] === "string");
|
|
34
|
+
}
|
|
35
|
+
function browserStorageStore(storageRef, opts = {}) {
|
|
36
|
+
const key = opts.key ?? DEFAULT_SESSION_STORAGE_KEY;
|
|
37
|
+
return {
|
|
38
|
+
get() {
|
|
39
|
+
const s = storageRef();
|
|
40
|
+
if (!s)
|
|
41
|
+
return null;
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = s.getItem(key);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!raw)
|
|
50
|
+
return null;
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Corrupted entry — wipe to recover.
|
|
57
|
+
try {
|
|
58
|
+
s.removeItem(key);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!isSessionShaped(parsed))
|
|
66
|
+
return null;
|
|
67
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
68
|
+
if (isExpired(parsed, nowSec)) {
|
|
69
|
+
// Auto-evict — let the caller see "no session" and re-auth.
|
|
70
|
+
try {
|
|
71
|
+
s.removeItem(key);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
},
|
|
80
|
+
set(session) {
|
|
81
|
+
const s = storageRef();
|
|
82
|
+
if (!s)
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
s.setItem(key, JSON.stringify(session));
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
// Quota exceeded, private mode, etc. — log but don't throw : the
|
|
89
|
+
// sign-in returned successfully, the in-memory session is still
|
|
90
|
+
// usable for this tab.
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn("[AithosAuth] failed to persist session:", e.message);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
clear() {
|
|
96
|
+
const s = storageRef();
|
|
97
|
+
if (!s)
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
s.removeItem(key);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function safeStorage(getter) {
|
|
109
|
+
return () => {
|
|
110
|
+
try {
|
|
111
|
+
const s = getter();
|
|
112
|
+
return s ?? null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Some restricted contexts (sandboxed iframes, file:// URLs) throw
|
|
116
|
+
// on access. Treat them as "no storage available".
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Default web store : `sessionStorage`. The session lives until the tab
|
|
123
|
+
* is closed. Cleared on `signOut()`. Use this when reauthenticating each
|
|
124
|
+
* day is acceptable and reduces blast radius after an XSS.
|
|
125
|
+
*/
|
|
126
|
+
export function sessionStorageStore(opts) {
|
|
127
|
+
return browserStorageStore(safeStorage(() => (typeof sessionStorage !== "undefined" ? sessionStorage : undefined)), opts);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `localStorage` store. The session persists until the JWT expires or the
|
|
131
|
+
* user explicitly signs out. Higher convenience, larger XSS blast radius.
|
|
132
|
+
*/
|
|
133
|
+
export function localStorageStore(opts) {
|
|
134
|
+
return browserStorageStore(safeStorage(() => (typeof localStorage !== "undefined" ? localStorage : undefined)), opts);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* No-op store. `set` and `clear` discard their input ; `get` always
|
|
138
|
+
* returns null. The default in non-browser contexts (Node, edge runtimes)
|
|
139
|
+
* — apps running there should pass their own store explicitly.
|
|
140
|
+
*/
|
|
141
|
+
export function noopStore() {
|
|
142
|
+
return {
|
|
143
|
+
get: () => null,
|
|
144
|
+
set: () => { },
|
|
145
|
+
clear: () => { },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Pick a sensible default : `sessionStorage` if the browser environment
|
|
150
|
+
* is available, {@link noopStore} otherwise.
|
|
151
|
+
*/
|
|
152
|
+
export function defaultSessionStore() {
|
|
153
|
+
if (typeof sessionStorage !== "undefined") {
|
|
154
|
+
return sessionStorageStore();
|
|
155
|
+
}
|
|
156
|
+
return noopStore();
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=session-store.js.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Unit tests for AithosAuth — Sign in with Google flow.
|
|
4
|
+
import { strict as assert } from "node:assert";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { AithosAuth, AithosSDKError } from "../src/index.js";
|
|
7
|
+
/** Tiny window-shim that records calls instead of actually navigating. */
|
|
8
|
+
function makeFakeWindow(initialHref) {
|
|
9
|
+
let href = initialHref;
|
|
10
|
+
let assigned = null;
|
|
11
|
+
let replacedHref = null;
|
|
12
|
+
const win = {
|
|
13
|
+
location: {
|
|
14
|
+
get href() {
|
|
15
|
+
return href;
|
|
16
|
+
},
|
|
17
|
+
assign(target) {
|
|
18
|
+
assigned = target;
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
history: {
|
|
22
|
+
replaceState(_state, _title, url) {
|
|
23
|
+
replacedHref = url;
|
|
24
|
+
href = url;
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
win: win,
|
|
30
|
+
get assigned() {
|
|
31
|
+
return assigned;
|
|
32
|
+
},
|
|
33
|
+
get replacedHref() {
|
|
34
|
+
return replacedHref;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function fakeSession(overrides = {}) {
|
|
39
|
+
return {
|
|
40
|
+
session: "jwt-token-here",
|
|
41
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
42
|
+
did: "did:aithos:zABC123",
|
|
43
|
+
handle: "alice-x9y2",
|
|
44
|
+
blob_b64: "",
|
|
45
|
+
blob_nonce_b64: "",
|
|
46
|
+
blob_version: 0,
|
|
47
|
+
enc_key_b64: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=",
|
|
48
|
+
is_first_login: true,
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/* -------------------------------------------------------------------------- */
|
|
53
|
+
/* signInWithGoogle */
|
|
54
|
+
/* -------------------------------------------------------------------------- */
|
|
55
|
+
describe("AithosAuth.signInWithGoogle", () => {
|
|
56
|
+
it("navigates to /auth/sso/google/start with no params by default", () => {
|
|
57
|
+
const w = makeFakeWindow("https://app.aithos.be/login");
|
|
58
|
+
const auth = new AithosAuth({
|
|
59
|
+
authBaseUrl: "https://auth.example.test",
|
|
60
|
+
window: w.win,
|
|
61
|
+
});
|
|
62
|
+
assert.throws(() => auth.signInWithGoogle(), AithosSDKError);
|
|
63
|
+
assert.equal(w.assigned, "https://auth.example.test/auth/sso/google/start");
|
|
64
|
+
});
|
|
65
|
+
it("forwards appState as the app_state query param", () => {
|
|
66
|
+
const w = makeFakeWindow("https://app.aithos.be/login");
|
|
67
|
+
const auth = new AithosAuth({
|
|
68
|
+
authBaseUrl: "https://auth.example.test",
|
|
69
|
+
window: w.win,
|
|
70
|
+
});
|
|
71
|
+
assert.throws(() => auth.signInWithGoogle({ appState: "/dashboard" }), AithosSDKError);
|
|
72
|
+
const url = new URL(w.assigned);
|
|
73
|
+
assert.equal(url.searchParams.get("app_state"), "/dashboard");
|
|
74
|
+
});
|
|
75
|
+
it("rejects appState longer than 1024 chars without navigating", () => {
|
|
76
|
+
const w = makeFakeWindow("https://app.aithos.be/login");
|
|
77
|
+
const auth = new AithosAuth({
|
|
78
|
+
authBaseUrl: "https://auth.example.test",
|
|
79
|
+
window: w.win,
|
|
80
|
+
});
|
|
81
|
+
const tooLong = "x".repeat(1025);
|
|
82
|
+
assert.throws(() => auth.signInWithGoogle({ appState: tooLong }), (e) => e instanceof AithosSDKError && e.code === "auth_app_state_too_long");
|
|
83
|
+
assert.equal(w.assigned, null, "must not have navigated");
|
|
84
|
+
});
|
|
85
|
+
it("trims a trailing slash from authBaseUrl", () => {
|
|
86
|
+
const w = makeFakeWindow("https://app.aithos.be/");
|
|
87
|
+
const auth = new AithosAuth({
|
|
88
|
+
authBaseUrl: "https://auth.example.test/",
|
|
89
|
+
window: w.win,
|
|
90
|
+
});
|
|
91
|
+
assert.throws(() => auth.signInWithGoogle(), AithosSDKError);
|
|
92
|
+
assert.equal(w.assigned, "https://auth.example.test/auth/sso/google/start");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
/* -------------------------------------------------------------------------- */
|
|
96
|
+
/* handleCallback */
|
|
97
|
+
/* -------------------------------------------------------------------------- */
|
|
98
|
+
describe("AithosAuth.handleCallback", () => {
|
|
99
|
+
it("returns null when the URL has no aithos_code", async () => {
|
|
100
|
+
const w = makeFakeWindow("https://app.aithos.be/auth/callback");
|
|
101
|
+
const auth = new AithosAuth({ window: w.win, fetch: undefinedFetch() });
|
|
102
|
+
const session = await auth.handleCallback();
|
|
103
|
+
assert.equal(session, null);
|
|
104
|
+
});
|
|
105
|
+
it("exchanges the code, returns the session, and strips query params", async () => {
|
|
106
|
+
const w = makeFakeWindow("https://app.aithos.be/auth/callback?aithos_code=abc123XYZ_-456&app_state=/dashboard");
|
|
107
|
+
const session = fakeSession({ is_first_login: true });
|
|
108
|
+
let capturedBody;
|
|
109
|
+
const fakeFetch = async (input, init) => {
|
|
110
|
+
assert.equal(typeof input === "string" ? input : input.toString(), "https://auth.example.test/auth/sso/exchange");
|
|
111
|
+
capturedBody = JSON.parse(init?.body);
|
|
112
|
+
return new Response(JSON.stringify(session), {
|
|
113
|
+
status: 200,
|
|
114
|
+
headers: { "content-type": "application/json" },
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
const auth = new AithosAuth({
|
|
118
|
+
authBaseUrl: "https://auth.example.test",
|
|
119
|
+
window: w.win,
|
|
120
|
+
fetch: fakeFetch,
|
|
121
|
+
});
|
|
122
|
+
const out = await auth.handleCallback();
|
|
123
|
+
assert.deepEqual(out, session);
|
|
124
|
+
assert.equal(capturedBody?.aithos_code, "abc123XYZ_-456");
|
|
125
|
+
assert.equal(w.replacedHref, "https://app.aithos.be/auth/callback", "callback params must be stripped from the URL");
|
|
126
|
+
});
|
|
127
|
+
it("throws AithosSDKError with the backend code on aithos_error", async () => {
|
|
128
|
+
const w = makeFakeWindow("https://app.aithos.be/auth/callback?aithos_error=google_id_token&app_state=/dashboard");
|
|
129
|
+
const auth = new AithosAuth({
|
|
130
|
+
authBaseUrl: "https://auth.example.test",
|
|
131
|
+
window: w.win,
|
|
132
|
+
fetch: undefinedFetch(),
|
|
133
|
+
});
|
|
134
|
+
await assert.rejects(auth.handleCallback(), (e) => e instanceof AithosSDKError && e.code === "auth_google_id_token");
|
|
135
|
+
// URL is cleaned even on error so a refresh doesn't loop the message.
|
|
136
|
+
assert.equal(w.replacedHref, "https://app.aithos.be/auth/callback");
|
|
137
|
+
});
|
|
138
|
+
it("wraps a 410 'code_consumed' as AithosSDKError(code='auth_code_consumed')", async () => {
|
|
139
|
+
const w = makeFakeWindow("https://app.aithos.be/auth/callback?aithos_code=abc123XYZ_-456");
|
|
140
|
+
const fakeFetch = async () => new Response(JSON.stringify({ error: "aithos_code expired or already used", code: "code_consumed" }), { status: 410, headers: { "content-type": "application/json" } });
|
|
141
|
+
const auth = new AithosAuth({
|
|
142
|
+
authBaseUrl: "https://auth.example.test",
|
|
143
|
+
window: w.win,
|
|
144
|
+
fetch: fakeFetch,
|
|
145
|
+
});
|
|
146
|
+
await assert.rejects(auth.handleCallback(), (e) => e instanceof AithosSDKError &&
|
|
147
|
+
e.code === "auth_code_consumed" &&
|
|
148
|
+
e.status === 410);
|
|
149
|
+
});
|
|
150
|
+
it("returns null in non-browser environments (no window)", async () => {
|
|
151
|
+
// No `window` injected and `globalThis.window` is undefined under Node test.
|
|
152
|
+
const auth = new AithosAuth({ window: undefined, fetch: undefinedFetch() });
|
|
153
|
+
const session = await auth.handleCallback();
|
|
154
|
+
assert.equal(session, null);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
/* -------------------------------------------------------------------------- */
|
|
158
|
+
/* signOut */
|
|
159
|
+
/* -------------------------------------------------------------------------- */
|
|
160
|
+
describe("AithosAuth.signOut", () => {
|
|
161
|
+
it("resolves immediately (sessions are stateless)", async () => {
|
|
162
|
+
const auth = new AithosAuth({ window: undefined, fetch: undefinedFetch() });
|
|
163
|
+
await auth.signOut();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
/* -------------------------------------------------------------------------- */
|
|
167
|
+
/* Helpers */
|
|
168
|
+
/* -------------------------------------------------------------------------- */
|
|
169
|
+
/** A fetch that fails the test if invoked — for code paths that mustn't fetch. */
|
|
170
|
+
function undefinedFetch() {
|
|
171
|
+
return async () => {
|
|
172
|
+
throw new Error("fetch should not have been called");
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=auth.test.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aithos/sdk",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.4",
|
|
4
4
|
"description": "Aithos SDK — high-level TypeScript developer kit for building agentic apps on the Aithos protocol. Wraps @aithos/protocol-client and exposes the Aithos compute proxy and wallet (Stripe top-up) endpoints.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aithos",
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"node": ">=20"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@aithos/protocol-client": ">=0.1.0-alpha.
|
|
55
|
+
"@aithos/protocol-client": ">=0.1.0-alpha.11 <0.2.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@aithos/protocol-client": "^0.1.0-alpha.
|
|
58
|
+
"@aithos/protocol-client": "^0.1.0-alpha.11",
|
|
59
59
|
"@types/node": "^24.12.2",
|
|
60
60
|
"typescript": "^5.9.2"
|
|
61
61
|
},
|