@bunbase-ae/js 2.4.0 → 2.4.1-next.162.9c38140
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/package.json +1 -1
- package/src/auth.ts +116 -7
- package/src/index.ts +2 -0
- package/src/types.ts +27 -0
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
AuthUser,
|
|
11
11
|
LoginResult,
|
|
12
12
|
PersistSessionOptions,
|
|
13
|
+
SwitchTenantResult,
|
|
14
|
+
TenantMembership,
|
|
13
15
|
TotpChallenge,
|
|
14
16
|
TotpSetup,
|
|
15
17
|
} from "./types";
|
|
@@ -17,10 +19,39 @@ import type {
|
|
|
17
19
|
// Reactive snapshot of auth state. Consumed by useAuth() via useSyncExternalStore.
|
|
18
20
|
export interface AuthSnapshot {
|
|
19
21
|
user: AuthUser | null;
|
|
22
|
+
/**
|
|
23
|
+
* Tenant the current session is scoped to. Derived from the access token's
|
|
24
|
+
* `tenant_id` claim. Null for unscoped sessions (login without tenantId,
|
|
25
|
+
* API-key auth, logged-out state).
|
|
26
|
+
*/
|
|
27
|
+
tenantId: string | null;
|
|
20
28
|
loading: boolean;
|
|
21
29
|
error: Error | null;
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
// Decode the base64url payload of a JWT to read claims client-side. Returns
|
|
33
|
+
// null for malformed tokens — callers treat that as "no tenant claim".
|
|
34
|
+
function decodeJwtPayload(jwt: string): Record<string, unknown> | null {
|
|
35
|
+
const parts = jwt.split(".");
|
|
36
|
+
const raw = parts[1];
|
|
37
|
+
if (parts.length !== 3 || !raw) return null;
|
|
38
|
+
try {
|
|
39
|
+
const b64 = raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
40
|
+
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
|
|
41
|
+
const decoded = atob(b64 + pad);
|
|
42
|
+
return JSON.parse(decoded) as Record<string, unknown>;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tenantIdFromAccessToken(accessToken: string | null | undefined): string | null {
|
|
49
|
+
if (!accessToken) return null;
|
|
50
|
+
const payload = decodeJwtPayload(accessToken);
|
|
51
|
+
const claim = payload?.tenant_id;
|
|
52
|
+
return typeof claim === "string" && claim.length > 0 ? claim : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
// Represents the active session passed to onAuthChange listeners.
|
|
25
56
|
// Token-based sessions carry access/refresh tokens; API-key sessions carry the key.
|
|
26
57
|
export type AuthSession = { accessToken: string; refreshToken: string } | { apiKey: string };
|
|
@@ -30,18 +61,24 @@ export class AuthClient {
|
|
|
30
61
|
// Cleared on logout. Lets useAuth() initialize synchronously without a network round-trip.
|
|
31
62
|
private cachedUser: AuthUser | null = null;
|
|
32
63
|
|
|
33
|
-
// External store for useSyncExternalStore — holds user/loading/error
|
|
34
|
-
private _snap: AuthSnapshot = { user: null, loading: false, error: null };
|
|
64
|
+
// External store for useSyncExternalStore — holds user/tenantId/loading/error.
|
|
65
|
+
private _snap: AuthSnapshot = { user: null, tenantId: null, loading: false, error: null };
|
|
35
66
|
private _snapSubs = new Set<() => void>();
|
|
36
67
|
|
|
37
68
|
constructor(private readonly http: HttpClient) {
|
|
38
|
-
// When tokens
|
|
39
|
-
// or server-pushed revocation via RealtimeClient), keep
|
|
40
|
-
// reactive snapshot in sync automatically.
|
|
69
|
+
// When tokens change (login, refresh, switch-tenant, logout, logoutAll,
|
|
70
|
+
// deleteAccount, or server-pushed revocation via RealtimeClient), keep
|
|
71
|
+
// cachedUser and the reactive snapshot in sync automatically.
|
|
72
|
+
//
|
|
73
|
+
// tenantId is derived from the access token's `tenant_id` claim on every
|
|
74
|
+
// transition, so callers never have to plumb it manually.
|
|
41
75
|
this.http.onTokenChange(({ accessToken }) => {
|
|
76
|
+
const tenantId = tenantIdFromAccessToken(accessToken);
|
|
42
77
|
if (!accessToken && this.cachedUser !== null) {
|
|
43
78
|
this.cachedUser = null;
|
|
44
|
-
this.patchSnapshot({ user: null });
|
|
79
|
+
this.patchSnapshot({ user: null, tenantId });
|
|
80
|
+
} else {
|
|
81
|
+
this.patchSnapshot({ tenantId });
|
|
45
82
|
}
|
|
46
83
|
});
|
|
47
84
|
}
|
|
@@ -91,7 +128,15 @@ export class AuthClient {
|
|
|
91
128
|
|
|
92
129
|
// Returns AuthResult on success, or TotpChallenge when 2FA is enabled.
|
|
93
130
|
// On TotpChallenge, call verifyTotp(result.totp_token, code) to complete sign-in.
|
|
94
|
-
|
|
131
|
+
//
|
|
132
|
+
// Pass `tenantId` to scope the issued session to a specific tenant — the
|
|
133
|
+
// access token then carries a `tenant_id` claim and all subsequent requests
|
|
134
|
+
// are restricted to that tenant. Omit it for unscoped sessions.
|
|
135
|
+
async login(credentials: {
|
|
136
|
+
email: string;
|
|
137
|
+
password: string;
|
|
138
|
+
tenantId?: string;
|
|
139
|
+
}): Promise<LoginResult> {
|
|
95
140
|
const result = await this.http.request<LoginResult>("POST", "/api/v1/auth/login", {
|
|
96
141
|
body: credentials,
|
|
97
142
|
skipAuth: true,
|
|
@@ -136,6 +181,41 @@ export class AuthClient {
|
|
|
136
181
|
return result;
|
|
137
182
|
}
|
|
138
183
|
|
|
184
|
+
// Redeem an OAuth handoff code for the token pair.
|
|
185
|
+
//
|
|
186
|
+
// The OAuth callback redirects to `{APP_URL}/auth/callback?handoff=<code>`.
|
|
187
|
+
// Tokens no longer travel through the URL query string (which would leak
|
|
188
|
+
// them into browser history, proxy logs, and third-party Referer headers);
|
|
189
|
+
// instead, the frontend reads the `handoff` param and posts it here.
|
|
190
|
+
//
|
|
191
|
+
// Single-use — a second call with the same code returns 404.
|
|
192
|
+
async exchangeHandoff(code: string): Promise<{
|
|
193
|
+
access_token: string;
|
|
194
|
+
refresh_token: string;
|
|
195
|
+
expires_in: number;
|
|
196
|
+
}> {
|
|
197
|
+
const result = await this.http.request<{
|
|
198
|
+
access_token: string;
|
|
199
|
+
refresh_token: string;
|
|
200
|
+
expires_in: number;
|
|
201
|
+
}>("POST", "/api/v1/auth/oauth/handoff", {
|
|
202
|
+
body: { handoff: code },
|
|
203
|
+
skipAuth: true,
|
|
204
|
+
});
|
|
205
|
+
this.http.setTokens(result.access_token, result.refresh_token);
|
|
206
|
+
// Populate the reactive snapshot so useAuth() lights up without the caller
|
|
207
|
+
// having to issue a separate me() round-trip. Swallow errors here — the
|
|
208
|
+
// token store is already primed; any auth-dependent view can retry.
|
|
209
|
+
try {
|
|
210
|
+
const user = await this.me();
|
|
211
|
+
this.cachedUser = user;
|
|
212
|
+
this.patchSnapshot({ user });
|
|
213
|
+
} catch {
|
|
214
|
+
// Caller can retry via auth.me() — tokens are already stored.
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
139
219
|
async refresh(): Promise<AuthResult> {
|
|
140
220
|
const refreshToken = this.http.getRefreshToken();
|
|
141
221
|
if (!refreshToken) throw new Error("No refresh token stored. Call login() first.");
|
|
@@ -325,6 +405,35 @@ export class AuthClient {
|
|
|
325
405
|
await this.http.request<{ ok: boolean }>("DELETE", `/api/v1/auth/api-keys/${id}`);
|
|
326
406
|
}
|
|
327
407
|
|
|
408
|
+
// ─── Multi-tenancy ───────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
// List the tenants the authenticated user is a member of.
|
|
411
|
+
// Use the returned `tenant_id` values with switchTenant() to re-scope the session.
|
|
412
|
+
async listTenants(): Promise<TenantMembership[]> {
|
|
413
|
+
const result = await this.http.request<{ items: TenantMembership[]; total: number }>(
|
|
414
|
+
"GET",
|
|
415
|
+
"/api/v1/auth/tenants",
|
|
416
|
+
);
|
|
417
|
+
return result.items;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Re-issue the session token pair scoped to a different tenant.
|
|
421
|
+
//
|
|
422
|
+
// The caller's current session is revoked server-side before the new pair is
|
|
423
|
+
// issued, so the pre-switch refresh/access tokens can no longer be used. The
|
|
424
|
+
// returned token pair is written into the HTTP layer automatically — the
|
|
425
|
+
// reactive AuthSnapshot picks up the new `tenant_id` claim on the next tick,
|
|
426
|
+
// so useAuth() callers see the updated `tenantId` without any extra wiring.
|
|
427
|
+
async switchTenant(tenantId: string): Promise<SwitchTenantResult> {
|
|
428
|
+
const result = await this.http.request<SwitchTenantResult>(
|
|
429
|
+
"POST",
|
|
430
|
+
"/api/v1/auth/switch-tenant",
|
|
431
|
+
{ body: { tenant_id: tenantId } },
|
|
432
|
+
);
|
|
433
|
+
this.http.setTokens(result.access_token, result.refresh_token);
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
|
|
328
437
|
// ─── Auth change listeners ────────────────────────────────────────────────
|
|
329
438
|
|
|
330
439
|
// Register a listener for token-based auth transitions: fires with
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -6,6 +6,11 @@ export interface BunBaseRecord {
|
|
|
6
6
|
_created_at: number;
|
|
7
7
|
_updated_at: number;
|
|
8
8
|
_owner_id: string | null;
|
|
9
|
+
/**
|
|
10
|
+
* Tenant this record belongs to. Null for records created outside a tenant-scoped session.
|
|
11
|
+
* Always present on the wire — the server stamps it on every record.
|
|
12
|
+
*/
|
|
13
|
+
_tenant_id: string | null;
|
|
9
14
|
_deleted_at?: number | null;
|
|
10
15
|
}
|
|
11
16
|
|
|
@@ -170,6 +175,7 @@ export type Filter<T = Record<string, unknown>> = {
|
|
|
170
175
|
_created_at?: FilterFieldValue<number>;
|
|
171
176
|
_updated_at?: FilterFieldValue<number>;
|
|
172
177
|
_owner_id?: FilterFieldValue<string | null>;
|
|
178
|
+
_tenant_id?: FilterFieldValue<string | null>;
|
|
173
179
|
_deleted_at?: FilterFieldValue<number | null | undefined>;
|
|
174
180
|
};
|
|
175
181
|
|
|
@@ -361,6 +367,27 @@ export interface ApiKey {
|
|
|
361
367
|
created_at: number;
|
|
362
368
|
}
|
|
363
369
|
|
|
370
|
+
// ─── Multi-tenancy ────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
/** A single tenant membership for the authenticated user. Returned by `AuthClient.listTenants()`. */
|
|
373
|
+
export interface TenantMembership {
|
|
374
|
+
tenant_id: string;
|
|
375
|
+
role: string;
|
|
376
|
+
created_at: number;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Returned by `AuthClient.switchTenant()`. Carries a re-issued token pair scoped
|
|
381
|
+
* to the target tenant. The caller's prior session is revoked server-side before
|
|
382
|
+
* the new pair is minted, so stale tokens cannot be replayed.
|
|
383
|
+
*/
|
|
384
|
+
export interface SwitchTenantResult {
|
|
385
|
+
access_token: string;
|
|
386
|
+
refresh_token: string;
|
|
387
|
+
expires_in: number;
|
|
388
|
+
tenant: { tenant_id: string; role: string };
|
|
389
|
+
}
|
|
390
|
+
|
|
364
391
|
// Minimal storage interface — deliberately async-compatible so the same API
|
|
365
392
|
// works with synchronous Web Storage (localStorage, sessionStorage) and
|
|
366
393
|
// Promise-based stores such as React Native's AsyncStorage or any custom
|