@finueva/auth 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,157 +1,475 @@
1
- # @finueva/auth
1
+ # `@finueva/auth`
2
2
 
3
- Experimental public SDK for Finueva Auth. Version `0.x` may introduce breaking changes; `1.0.0` will mark the stable interface.
3
+ The official TypeScript client for Finueva Auth.
4
+
5
+ Use it to:
6
+
7
+ - sign users in with email and password;
8
+ - read the current session;
9
+ - manage profile and password details;
10
+ - list organizations and accept invitations;
11
+ - request short-lived Drive credentials;
12
+ - authenticate CLIs and native applications.
13
+
14
+ The package is ESM-only and has no runtime dependencies. Its API is experimental while the package is below `1.0.0`.
15
+
16
+ ## Install
4
17
 
5
18
  ```sh
6
19
  pnpm add @finueva/auth
7
20
  ```
8
21
 
9
- ## Browser
22
+ Node.js applications require Node.js 22 or later.
23
+
24
+ ## Pick The Right Client
25
+
26
+ | Runtime | Import | How it authenticates |
27
+ | ------------------------- | ---------------------- | ------------------------------------ |
28
+ | Browser | `@finueva/auth` | Finueva Auth's HTTP-only cookie |
29
+ | Server or SSR handler | `@finueva/auth/server` | Cookies or one incoming bearer token |
30
+ | CLI or native application | `@finueva/auth/device` | A device bearer session |
31
+
32
+ Create one browser client for the application, one server client per request, or one device client per application process.
33
+
34
+ Before you start, ask the Auth operator to register your exact application origin. Device applications also need a registered client ID. Do not copy values from another application or environment.
35
+
36
+ ## Browser Quick Start
10
37
 
11
38
  ```ts
12
- import { createAuthClient } from "@finueva/auth";
39
+ import { AuthError, createAuthClient } from "@finueva/auth";
13
40
 
14
41
  const auth = createAuthClient({
15
42
  authOrigin: "https://auth.finueva.com",
16
43
  appOrigin: window.location.origin,
17
44
  });
18
45
 
19
- if (auth._type !== "browser") throw new Error("Expected a browser Auth client");
46
+ const session = await auth.getSession();
47
+
48
+ if (session) {
49
+ console.log(`Signed in as ${session.user.email}`);
50
+ } else {
51
+ console.log("Not signed in");
52
+ }
53
+
54
+ try {
55
+ const nextSession = await auth.signIn({
56
+ email: "ada@example.com",
57
+ password: "correct-horse-battery-staple",
58
+ });
59
+
60
+ console.log(`Welcome ${nextSession.user.name}`);
61
+ } catch (error) {
62
+ if (error instanceof AuthError && error.code === "invalid_credentials") {
63
+ console.log("Check the email and password.");
64
+ } else {
65
+ throw error;
66
+ }
67
+ }
68
+ ```
69
+
70
+ Auth keeps the session token in an HTTP-only cookie. The browser client never returns that token.
20
71
 
72
+ ### React To Session Changes
73
+
74
+ Use `onAuthStateChange()` to update the interface:
75
+
76
+ ```ts
21
77
  const stop = auth.onAuthStateChange((state) => {
22
- if (state.status === "cached") renderSessionHint(state.session);
23
- if (state.status === "authenticated") renderVerifiedSession(state.session);
78
+ switch (state.status) {
79
+ case "loading":
80
+ showLoading();
81
+ break;
82
+ case "cached":
83
+ showAccountHint(state.session.user);
84
+ break;
85
+ case "authenticated":
86
+ showAccount(state.session.user);
87
+ break;
88
+ case "unauthenticated":
89
+ showSignIn();
90
+ break;
91
+ case "unavailable":
92
+ showAuthUnavailable();
93
+ break;
94
+ }
24
95
  });
25
96
 
26
- const session = await auth.getSession();
27
- await auth.signUp(email, password, name);
28
- await auth.signIn({ email, password });
29
- await auth.signInWithSocialIdToken({ provider: "google", idToken, nonce, signUp: false });
30
- await auth.updateProfile({ name, image });
31
- await auth.changePassword(currentPassword, newPassword); // revokes other sessions by default
32
- await auth.requestPasswordReset(email, `${window.location.origin}/account/reset-password`);
33
- await auth.resetPassword(token, newPassword);
34
- await auth.sendVerificationEmail({
35
- email,
36
- callbackURL: `${window.location.origin}/account/email-verified`,
97
+ window.addEventListener("pagehide", stop, { once: true });
98
+ ```
99
+
100
+ `cached` state is only a display hint. Never use cached browser state to authorize an operation. Check access on the server.
101
+
102
+ Browser snapshots use local storage by default. Choose another mode when you create the client:
103
+
104
+ ```ts
105
+ const auth = createAuthClient({
106
+ authOrigin: "https://auth.finueva.com",
107
+ appOrigin: window.location.origin,
108
+ persistence: "session", // "local", "session", or "memory"
109
+ timeoutMs: 10_000,
37
110
  });
38
- const organizations = await auth.listOrganizations();
39
- const acceptance = await auth.acceptInvitation({ invitationId });
40
- const credential = await auth.getDriveCredential({
41
- workspace: { type: "personal" },
111
+ ```
112
+
113
+ The snapshot contains normalized display data, not cookies or Drive credentials.
114
+
115
+ ## Common Browser Tasks
116
+
117
+ ### Create An Account
118
+
119
+ ```ts
120
+ const session = await auth.signUp("ada@example.com", "correct-horse-battery-staple", "Ada Lovelace");
121
+ ```
122
+
123
+ New passwords must contain 10 to 128 characters.
124
+
125
+ ### Update The Profile
126
+
127
+ ```ts
128
+ const session = await auth.updateProfile({
129
+ name: "Ada Lovelace",
130
+ image: "https://images.example.com/ada.png",
42
131
  });
43
- const organizationCredential = await auth.getDriveCredential({
44
- workspace: { type: "organization", organizationId: organizations[0].id },
132
+ ```
133
+
134
+ Pass `image: null` to remove the image.
135
+
136
+ ### Change The Password
137
+
138
+ ```ts
139
+ await auth.changePassword(currentPassword, newPassword);
140
+ ```
141
+
142
+ This revokes the user's other sessions by default. Pass `false` as the third argument only when the product deliberately keeps those sessions.
143
+
144
+ ### Reset A Password
145
+
146
+ ```ts
147
+ await auth.requestPasswordReset("ada@example.com", `${window.location.origin}/account/reset-password`);
148
+ ```
149
+
150
+ On the reset page, remove the token from the address bar before loading other resources. Use it once:
151
+
152
+ ```ts
153
+ const url = new URL(window.location.href);
154
+ let token = url.searchParams.get("token");
155
+
156
+ url.searchParams.delete("token");
157
+ window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
158
+
159
+ if (!token) throw new Error("Reset token is missing");
160
+
161
+ try {
162
+ await auth.resetPassword(token, newPassword);
163
+ } finally {
164
+ token = null;
165
+ }
166
+ ```
167
+
168
+ Serve this page with `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. Do not load analytics or third-party content on it.
169
+
170
+ ### Verify An Email
171
+
172
+ ```ts
173
+ await auth.sendVerificationEmail({
174
+ email: "ada@example.com",
175
+ callbackURL: `${window.location.origin}/account/email-verified`,
45
176
  });
46
- await auth.signOut();
47
- stop();
48
177
  ```
49
178
 
50
- The Auth session uses the Auth-owned HTTP-only cookie. `getSession()` reports one bounded Better Auth authority result. Every authoritative session read receives a monotonic epoch, and account transitions advance that epoch again; only the newest read may update state or persistence, while a delayed older caller receives value-free `state_conflict`. `onSessionChange()` starts authoritative loading by itself and is an SDK-owned projection: it emits only sessions/null accepted through that bounded path, never Better Auth client cache state or persisted hints. The SDK subscribes once to Better Auth's session atom only as an invalidation source; focus, online, local mutation, and cross-tab storage signals trigger a coalesced bounded SDK `get-session` refresh, while raw `useSession.data` is ignored. Signals wait during account mutation, and the current sign-out signal cycle is suppressed so prior authority cannot race the owning transition. Better Auth `1.6.22` broadcasts sign-out between tabs but not sign-in, so a background tab observes sign-in on its next focus or online invalidation. Listener exceptions are isolated from authority work and other listeners. `onAuthStateChange()` separately emits `loading`, non-authoritative `cached`, authoritative `authenticated`/`unauthenticated`, or non-authoritative `unavailable`; it verifies every cache hint immediately.
179
+ The callback must use the application's exact origin. Omit it to use the default Auth result page.
51
180
 
52
- `sendVerificationEmail({ email, callbackURL? })` sends one non-retried request. An explicit callback must be an exact absolute URL on `appOrigin`; it is never persisted by the SDK. Without one, the email lands on Auth's value-free verification result page after Better Auth completes verification.
181
+ ### List Organizations
53
182
 
54
- `signUp(email, password, name)`, `signIn({ email, password })`, `signInWithSocialIdToken({ provider, idToken, nonce?, signUp })`, `updateProfile({ name?, image? })`, and `changePassword(currentPassword, newPassword, revokeOtherSessions?)` delegate session ownership to Better Auth and return only the subsequently refreshed normalized session. Social providers are closed to `google | apple`, `signUp` is required, the ID token is copied and limited to 16 KiB, and an optional nonce is copied and limited to 256 printable ASCII bytes. At mutation start these methods synchronously invalidate the prior session ID, persisted-snapshot projection, auth-state and session-listener projections, and Drive authority. Physical browser-persistence removal is bounded best-effort asynchronous work. Only the bounded post-mutation refresh can publish the next session or null; refresh failure leaves the prior account absent and state unavailable. They never expose Better Auth's raw session token. Emails are trimmed and lowercased, names are trimmed, existing passwords accepted for sign-in or current-password proof contain 8 to 128 characters, new passwords require 10 to 128 characters, and a profile image must be `null` or one exact HTTPS URL (HTTP is accepted only on localhost). Profile updates require at least one field. SCIM-managed names remain provider-owned and cannot be changed through this method; image-only updates remain available.
183
+ ```ts
184
+ let page = await auth.listOrganizations({ limit: 50 });
55
185
 
56
- `changePassword` defaults `revokeOtherSessions` to `true`, so Better Auth replaces the current session after removing the others. `requestPasswordReset(email, redirectTo)` requires an exact absolute callback on this client's `appOrigin`; the backend returns the same value-free success whether the account exists or not. `resetPassword(token, newPassword)` consumes Better Auth's one-time token, revokes existing sessions, and refreshes local authority. Password reset can therefore succeed even when a later session refresh is unavailable; do not automatically replay a consumed reset token.
186
+ for (const organization of page.organizations) {
187
+ console.log(organization.id, organization.name, organization.slug);
188
+ }
57
189
 
58
- `signOut()` returns `state_conflict` without making a request while an account mutation is active. Wait for that mutation to settle before signing out so a delayed mutation response cannot apply a later session cookie after a completed sign-out.
190
+ if (page.nextCursor) {
191
+ page = await auth.listOrganizations({ limit: 50, cursor: page.nextCursor });
192
+ }
193
+ ```
59
194
 
60
- DOM browsers use local storage by default. Set `persistence` to `"session"`, `"memory"`, or a structural driver with `getItem`, `setItem`, and `removeItem`; Workers and Node default to memory. The one 16 KiB versioned snapshot contains only a normalized session and its Auth authority identity. It expires with the session and never contains cookies, passwords, Drive credentials, tokens, headers, workspace selection, Better Auth payloads, or errors. Persistence failure cannot change an Auth request result.
195
+ All organization collections use an opaque cursor and a page size from 1 through 100. Use
196
+ `getOrganization()`, `listOrganizationMembers()`, `listOrganizationTeams()`, and
197
+ `listOrganizationTeamMembers()` with an explicit organization ID. Manual owners and admins can use
198
+ `listOrganizationInvitations()` and `getOrganizationInvitation()`. A verified recipient can use
199
+ `listReceivedInvitations()` and `getReceivedInvitation()`.
61
200
 
62
- A workspace is either `{ type: "personal" }` or `{ type: "organization", organizationId }`; organization IDs must match `[A-Za-z0-9_-]{1,128}`. Workspace selections are strict and reject additional properties locally with `invalid_workspace_selection` before making a request.
201
+ Pagination is deterministic while a collection does not change. During concurrent mutation, an
202
+ insert at or before the cursor can be absent, an insert after it can appear once, and a delete,
203
+ update, status change, or expiry can change later pages. Restart pagination when you need a current
204
+ view.
63
205
 
64
- `listOrganizations()` returns strict current `{ id, name, slug }` records from Better Auth authority. Results are not cached or persisted. Because the current upstream endpoint is unpaginated and silently capped at 100 records, the SDK fails closed rather than return an ambiguous list when that cap is reached.
206
+ ### Accept An Invitation
65
207
 
66
- `acceptInvitation({ invitationId })` performs one non-retried Better Auth acceptance and returns only `{ organizationId }`. Invitation details, recipient email, inviter identity, membership payloads, and raw authority errors are never exposed or persisted. Unknown, expired, canceled, accepted, and concurrently claimed invitations all report the same value-free `invitation_unavailable` conflict.
208
+ ```ts
209
+ const result = await auth.acceptInvitation({
210
+ invitationId: "invitation_01J8R7M6N5P4Q3S2T1",
211
+ });
67
212
 
68
- The browser retains at most one completed Drive credential in memory and renews it once it has two minutes remaining. Concurrent requests for the same workspace share one credential request. Selecting a different workspace never reuses the completed or in-flight credential for the previous workspace and replaces the retained credential. Passing `forceRefresh: true` requests a replacement after a Drive authentication failure; the SDK never replays a Drive request.
213
+ console.log(result.organizationId);
214
+ ```
69
215
 
70
- ## Server
216
+ ### Sign Out
71
217
 
72
218
  ```ts
73
- import { createServerAuth } from "@finueva/auth/server";
219
+ await auth.signOut();
220
+ ```
74
221
 
75
- const auth = createServerAuth({
76
- authOrigin: "https://auth.finueva.com",
77
- appOrigin: "https://files.finueva.com",
78
- request,
222
+ ## Request A Drive Credential
223
+
224
+ Ask Auth for a credential for one workspace:
225
+
226
+ ```ts
227
+ const credential = await auth.getDriveCredential({
228
+ workspace: { type: "personal" },
79
229
  });
80
230
 
81
- const session = await auth.getSession();
82
- const organizations = await auth.listOrganizations();
83
- const acceptance = await auth.acceptInvitation({ invitationId });
231
+ const authorization = `${credential.tokenType} ${credential.token}`;
232
+ ```
233
+
234
+ For an organization workspace:
235
+
236
+ ```ts
84
237
  const credential = await auth.getDriveCredential({
85
- workspace: { type: "organization", organizationId: organizations[0].id },
238
+ workspace: {
239
+ type: "organization",
240
+ organizationId: "org_01J8R7M6N5P4Q3S2T1",
241
+ },
242
+ });
243
+ ```
244
+
245
+ Drive credentials last about 14 minutes. Browser and device clients keep them in memory only and refresh them when two minutes remain. The server client does not cache them.
246
+
247
+ If Drive rejects a credential, request a replacement:
248
+
249
+ ```ts
250
+ const replacement = await auth.getDriveCredential({
251
+ workspace: { type: "personal" },
252
+ forceRefresh: true,
86
253
  });
87
- const signOut = await auth.signOut();
88
- const responseHeaders = new Headers();
89
- signOut.applyCookies(responseHeaders);
90
254
  ```
91
255
 
92
- The server client is request-scoped and does not cache credentials. Without `Authorization`, it forwards only reserved Better Auth cookies to Auth and never sends them to Drive. When the incoming request contains one valid, at-most-16-KiB `Bearer` authorization value, every Auth call forwards that exact header and omits all cookies; bearer authority always wins a mixed request. Server social ID-token sign-in therefore rejects locally with value-free `state_conflict` and makes no request when the incoming request has `Authorization`, rather than switching from bearer to newly minted cookie authority. Malformed or oversized authorization fails locally with value-free `authentication_required` and never falls back to a cookie. Apply cookies returned by server social sign-in, invitation acceptance, or sign-out to the outgoing response with the result's `applyCookies(headers)` method.
256
+ The SDK does not replay the failed Drive request. Replay it only when the operation is safe to repeat.
93
257
 
94
- ### Server-Owned Google Callback
258
+ ## Server And SSR
95
259
 
96
- HeyloMeet's existing BFF may retain ownership of Google authorization, state, PKCE, and callback handling. After its maintained OAuth client exchanges the code with Google, pass only the returned Google ID token and the original nonce to Auth. Auth independently verifies the token, returns a normalized authoritative Better Auth session plus cookies, and never redirects to Google or returns provider/session tokens to the browser:
260
+ Create a server client inside each request handler. It forwards only Finueva Auth cookies. If the incoming request has a bearer token, that token takes precedence and the client forwards no cookies.
97
261
 
98
262
  ```ts
99
- const auth = createServerAuth({ authOrigin, appOrigin, request });
100
- const signedIn = await auth.signInWithSocialIdToken({
101
- provider: "google",
102
- idToken: googleTokenSet.id_token,
103
- nonce: callbackNonce,
104
- signUp: false,
263
+ import { AuthError, createServerAuth } from "@finueva/auth/server";
264
+
265
+ export async function handleAccountRequest(request: Request): Promise<Response> {
266
+ const auth = createServerAuth({
267
+ authOrigin: "https://auth.finueva.com",
268
+ appOrigin: "https://files.finueva.com",
269
+ request,
270
+ });
271
+
272
+ try {
273
+ const session = await auth.getSession();
274
+
275
+ if (!session) {
276
+ return Response.json(
277
+ { authenticated: false },
278
+ {
279
+ status: 401,
280
+ headers: { "cache-control": "no-store" },
281
+ },
282
+ );
283
+ }
284
+
285
+ return Response.json(
286
+ { user: session.user },
287
+ {
288
+ headers: { "cache-control": "no-store" },
289
+ },
290
+ );
291
+ } catch (error) {
292
+ if (error instanceof AuthError) {
293
+ return Response.json(
294
+ { code: error.code },
295
+ {
296
+ status: error.status,
297
+ headers: { "cache-control": "no-store" },
298
+ },
299
+ );
300
+ }
301
+
302
+ throw error;
303
+ }
304
+ }
305
+ ```
306
+
307
+ Some server mutations return cookies. Apply them to the outgoing response:
308
+
309
+ ```ts
310
+ const result = await auth.signOut();
311
+ const headers = new Headers({
312
+ "cache-control": "no-store",
313
+ location: "/signed-out",
105
314
  });
106
- const headers = new Headers({ location: "/" });
107
- signedIn.applyCookies(headers);
108
- return new Response(null, { status: 302, headers });
315
+
316
+ result.applyCookies(headers);
317
+
318
+ return new Response(null, { status: 303, headers });
109
319
  ```
110
320
 
111
- Set `signUp: true` only for an explicit product signup action. Do not put the Google ID token, Better Auth cookie, or Better Auth session token in the redirect URL, application cookie, logs, or page state. Do not forward Google access or refresh tokens to Auth.
321
+ Call `applyCookies()` after server `signOut()`, `acceptInvitation()`, and `signInWithSocialIdToken()`. If you omit it, the browser does not receive the session change.
322
+
323
+ Check `Origin`, Fetch Metadata, or a server-generated CSRF token before a server-side mutation. The SDK forwards authority; it does not replace your application's CSRF protection.
112
324
 
113
- ## Device
325
+ ## CLI And Native Applications
326
+
327
+ Use the device flow when a CLI or device can open a system browser:
114
328
 
115
329
  ```ts
116
- import { createDeviceAuth } from "@finueva/auth/device";
330
+ import { AuthError, DeviceAuthorizationError, createDeviceAuth } from "@finueva/auth/device";
117
331
 
118
332
  const auth = createDeviceAuth({
119
333
  authOrigin: "https://auth.finueva.com",
120
334
  clientId: "finueva-cli",
121
335
  });
122
- if (auth._type !== "device") throw new Error("Expected a device Auth client");
123
- const authorization = await auth.requestAuthorization();
124
- showCode(authorization.userCode, authorization.verificationUri);
125
- const token = await auth.pollForToken(authorization, { signal });
126
- const session = await auth.getSession();
127
- await auth.signUpWithEmail(email, password, name); // direct first-party mobile flow
128
- await auth.signInWithEmail(email, password);
129
- await auth.signInWithSocialIdToken({ provider: "apple", idToken, nonce: rawNonce, signUp: false });
130
- await auth.updateProfile({ name, image });
131
- await auth.changePassword(currentPassword, newPassword);
132
- const credential = await auth.getDriveCredential({ workspace: { type: "personal" } });
133
- await auth.revokeCurrentDevice(); // or revokeAllSessions()
336
+
337
+ const controller = new AbortController();
338
+ process.once("SIGINT", () => controller.abort());
339
+
340
+ try {
341
+ const authorization = await auth.requestAuthorization({
342
+ signal: controller.signal,
343
+ });
344
+
345
+ console.log(`Open ${authorization.verificationUriComplete}`);
346
+ console.log(`Code: ${authorization.userCode}`);
347
+
348
+ await auth.pollForToken(authorization, {
349
+ signal: controller.signal,
350
+ });
351
+
352
+ const session = await auth.getSession();
353
+ console.log(session ? `Signed in as ${session.user.email}` : "Not signed in");
354
+ } catch (error) {
355
+ if (error instanceof DeviceAuthorizationError || error instanceof AuthError) {
356
+ console.error(error.code);
357
+ } else {
358
+ throw error;
359
+ }
360
+ }
361
+ ```
362
+
363
+ `clientId` is a public registered identifier, not a secret. Never display or log `authorization.deviceCode`.
364
+
365
+ Device sessions are memory-only unless you provide `DeviceTokenPersistence`:
366
+
367
+ ```ts
368
+ import { createDeviceAuth, type DeviceTokenPersistence } from "@finueva/auth/device";
369
+
370
+ const persistence: DeviceTokenPersistence = {
371
+ read: (signal) => vault.read("finueva-auth", signal),
372
+ replace: (token, signal) => vault.replace("finueva-auth", token, signal),
373
+ delete: (signal) => vault.delete("finueva-auth", signal),
374
+ };
375
+
376
+ const auth = createDeviceAuth({
377
+ authOrigin: "https://auth.finueva.com",
378
+ clientId: "finueva-desktop",
379
+ fetch: nativeFetch,
380
+ persistence,
381
+ });
134
382
  ```
135
383
 
136
- Polling starts at the server-provided interval, adds five seconds after each RFC 8628 `slow_down`, has no transport retry, and stops on denial, expiry, cancellation, the configured `maxPollingMs`, malformed/bounded responses, or transport timeout. A bounded access token recoverable from otherwise malformed redemption metadata is best-effort revoked and no local authority is retained. The only requested scope is `openid`.
384
+ Use the operating system's credential vault. `replace()` must replace the complete token atomically. Do not use local storage, a WebView cookie jar, or a plaintext file. A native application must also supply a reviewed native HTTPS transport that omits ambient WebView cookies.
137
385
 
138
- The token is memory-only by default. Secure persistence is opt-in through a structural adapter implementing `read`, atomic `replace`, and `delete`; every operation receives an `AbortSignal`. The SDK serializes polling, account changes, expiry synchronization, and revocation, including persistence calls. Malformed non-null persisted authority is best-effort deleted before hydration fails, allowing a later process to start without the bad value. The caller owns encryption and platform credential storage. Adapter and authority failures are value-free and never include device codes or tokens. This export uses standard expiring/revocable Better Auth sessions and does not implement refresh tokens.
386
+ Direct first-party native email sign-in is available to registered applications:
139
387
 
140
- Browser and device clients expose the literal `_type` values `"browser"` and `"device"`. Use that discriminant to narrow a union before invoking runtime-specific methods; do not infer the client type from environment globals.
388
+ ```ts
389
+ const session = await auth.signInWithEmail("ada@example.com", "correct-horse-battery-staple");
390
+ ```
391
+
392
+ End the current device session with `revokeCurrentDevice()`. End every browser and device session for the account with `revokeAllSessions()`.
393
+
394
+ ## Social ID Tokens
395
+
396
+ Browser, server, and device clients accept Google and Apple ID tokens from a first-party provider integration:
397
+
398
+ ```ts
399
+ const session = await auth.signInWithSocialIdToken({
400
+ provider: "google",
401
+ idToken,
402
+ nonce,
403
+ signUp: false,
404
+ });
405
+ ```
406
+
407
+ Set `signUp: true` only after the user chooses to create an account. The application owns the provider authorization flow, state, nonce, and PKCE transaction. Pass only the ID token to Auth. Do not pass provider access or refresh tokens.
141
408
 
142
- Direct email and social signup/sign-in are intended for registered first-party mobile or Tauri clients. The SDK verifies the opaque token through bearer `get-session`, derives its expiry from that authoritative session, atomically replaces secure persistence, and only then publishes it in memory. Verification or replacement failure triggers best-effort revocation of the new session and retains the previous token only after current authority verifies it. A successful account switch best-effort revokes the replaced current-device session after publishing the new token. Password replacement follows the same rule; when Better Auth returns no replacement token, the current token is retained only after an authoritative session check. `getSession()` synchronizes changed authoritative expiry back to persistence.
409
+ The generic Google and Apple redirect flow is not exposed by this package.
143
410
 
144
- For Tauri, implement `DeviceTokenPersistence` in Rust commands backed by the operating system credential vault (Keychain, Credential Manager, or Secret Service). Store the complete `DeviceToken` as one encrypted/versioned item, make replacement atomic, and restrict access to this application identity. An `AbortSignal` cannot cancel a native `invoke` that has already been dispatched. The SDK retains an internal persistence queue after public timeout, runs compensating deletion after a late replacement, and rejects later authority operations with `state_conflict` until that queue settles. The process can still terminate between a native commit and compensation; production adapters need a native pending-operation journal or equivalent startup cleanup before returning a stored token. This process-crash recovery remains outside SDK qualification. Do not use WebView local storage, a plaintext file, Tauri Store, logs, crash metadata, or frontend state. Drive credentials remain memory-only even when the Better Auth session uses secure OS persistence; the device client keeps only one selected workspace credential and renews it with two minutes remaining. It permits at most one issuance request: an exact same-workspace non-forced call may share that request, while a workspace switch or forced refresh returns `state_conflict` until it settles.
411
+ ## Errors
412
+
413
+ Invalid local arguments throw `TypeError`. Auth failures throw `AuthError`:
414
+
415
+ ```ts
416
+ try {
417
+ await auth.getSession();
418
+ } catch (error) {
419
+ if (!(error instanceof AuthError)) throw error;
420
+
421
+ if (error.code === "auth_request_rate_limited") {
422
+ scheduleRetry(error.retryAfterSeconds ?? 60);
423
+ } else if (error.code === "identity_authority_unavailable") {
424
+ showAuthUnavailable();
425
+ } else {
426
+ showAuthError(error.code);
427
+ }
428
+ }
429
+ ```
145
430
 
146
- Tauri must also supply `createDeviceAuth({ fetch })` with a native HTTP adapter or move the complete Auth transport into Rust. Native Google or Apple authorization submits the provider ID token through `signInWithSocialIdToken`; the adapter must emit neither WebView cookies nor a WebView `Origin`, and every device SDK request uses `credentials: "omit"`. The default WebView fetch path is unsupported for `tauri://` applications and environments that send `Origin: null`. Device authorization remains available by opening `verificationUriComplete` in the system browser.
431
+ Use `error.code` for application behavior. Do not parse `error.message`.
432
+
433
+ | Code | What the application should do |
434
+ | -------------------------------- | --------------------------------------------------------------------- |
435
+ | `invalid_credentials` | Ask the user to check the credential. |
436
+ | `invalid_auth_input` | Correct the account input. |
437
+ | `account_conflict` | Stop the account action. Do not expose account-existence details. |
438
+ | `authentication_required` | Start sign-in. |
439
+ | `email_verification_required` | Ask the user to verify the current email. |
440
+ | `invitation_unavailable` | Ask for a new invitation. Do not retry the old one. |
441
+ | `invalid_workspace_selection` | Send one exact personal or organization workspace. |
442
+ | `state_conflict` | Wait for the active authority operation, then read the session again. |
443
+ | `access_denied` | Stop the operation. |
444
+ | `credential_rate_limited` | Wait for `retryAfterSeconds` when present. |
445
+ | `auth_request_rate_limited` | Wait for `retryAfterSeconds` when present. |
446
+ | `identity_authority_unavailable` | Keep state unverified and try again later. |
447
+
448
+ Device authorization can also throw `DeviceAuthorizationError`. Its codes are `device_access_denied`, `device_authorization_cancelled`, `device_code_expired`, `invalid_device_authorization`, and `identity_authority_unavailable`.
449
+
450
+ The SDK does not automatically retry requests.
451
+
452
+ ## Security Notes
453
+
454
+ - Use HTTPS. Plain HTTP is accepted only on localhost.
455
+ - Use exact registered origins. Do not build an Auth origin from the current request host.
456
+ - Keep browser session snapshots display-only.
457
+ - Create server clients per request. Do not keep one in a module singleton.
458
+ - Apply returned cookies before sending a server response.
459
+ - Keep device bearer tokens in an operating-system credential vault.
460
+ - Keep Drive credentials in memory and send them only to Drive.
461
+ - Do not log cookies, bearer tokens, ID tokens, reset tokens, device codes, or complete Auth responses.
462
+ - Protect application mutations against CSRF before calling the server client.
147
463
 
148
464
  ## Current Limits
149
465
 
150
- - Organization mutation and active-organization state are not exposed; callers select an organization returned by `listOrganizations()` explicitly.
151
- - Cross-root SSO is not implemented.
152
- - Cross-root redirect SSO and generic Better Auth Google/Apple social redirects are unsupported; an application or native provider SDK must obtain the ID token.
153
- - Apple first signup without a verified signed email is unsupported. Requests with caller-supplied Apple email/name fields are rejected; a later email-less token works only for an already-linked Apple subject with a current verified Auth email.
154
- - The package does not persist Drive credentials or expose Better Auth or Unstorage internals.
155
- - Account deletion is intentionally not exposed by browser, server, or device exports.
156
- - Public errors are `AuthError` instances with stable, value-free codes.
157
- - Organization contexts with more than 50 current teams fail with `organization_context_too_large` and status `422`.
466
+ - The public API is experimental and can change before `1.0.0`.
467
+ - Requests time out after 10 seconds by default. `timeoutMs` accepts 1 to 60,000 milliseconds.
468
+ - Auth JSON responses are limited to 64 KiB.
469
+ - Browser snapshots and bearer authorization values are limited to 16 KiB.
470
+ - Organization lists fail closed at 100 records.
471
+ - Organization Drive context supports at most 50 current teams.
472
+ - Account deletion and organization management are not exposed.
473
+ - Cross-root SSO and custom refresh tokens are not implemented.
474
+ - Hosted mobile transport and operating-system vault qualification are incomplete.
475
+ - Production support gates are not complete.
package/dist/device.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as AuthErrorStatus, S as AuthErrorCode, _ as SocialIdTokenInput, l as DriveCredential, o as AuthSession, u as DriveCredentialInput, v as SocialIdTokenProvider, x as AuthError, y as UpdateProfileInput } from "./types-DDopQNZR.js";
1
+ import { A as UpdateProfileInput, M as AuthError, N as AuthErrorCode, O as SocialIdTokenInput, P as AuthErrorStatus, T as ReceivedOrganizationInvitationPage, _ as OrganizationPage, b as OrganizationTeamMemberPage, g as OrganizationMemberPage, h as OrganizationMember, k as SocialIdTokenProvider, l as DriveCredential, m as OrganizationInvitationPage, o as AuthSession, p as OrganizationInvitation, r as AuthOrganization, u as DriveCredentialInput, v as OrganizationPageInput, w as ReceivedOrganizationInvitation, x as OrganizationTeamPage, y as OrganizationTeam } from "./types-B67VIvTQ.js";
2
2
  //#region src/device.d.ts
3
3
  type DeviceAuthorizationErrorCode = "device_access_denied" | "device_authorization_cancelled" | "device_code_expired" | "invalid_device_authorization" | "identity_authority_unavailable";
4
4
  declare class DeviceAuthorizationError extends Error {
@@ -43,6 +43,15 @@ interface DeviceAuthClient {
43
43
  }): Promise<DeviceAuthorization>;
44
44
  pollForToken(authorization: DeviceAuthorization, options?: PollDeviceOptions): Promise<DeviceToken>;
45
45
  getSession(): Promise<AuthSession | null>;
46
+ listOrganizations(input?: OrganizationPageInput): Promise<OrganizationPage>;
47
+ getOrganization(organizationId: string): Promise<AuthOrganization>;
48
+ listOrganizationMembers(organizationId: string, input?: OrganizationPageInput): Promise<OrganizationMemberPage>;
49
+ listOrganizationTeams(organizationId: string, input?: OrganizationPageInput): Promise<OrganizationTeamPage>;
50
+ listOrganizationTeamMembers(organizationId: string, teamId: string, input?: OrganizationPageInput): Promise<OrganizationTeamMemberPage>;
51
+ listOrganizationInvitations(organizationId: string, input?: OrganizationPageInput): Promise<OrganizationInvitationPage>;
52
+ getOrganizationInvitation(organizationId: string, invitationId: string): Promise<OrganizationInvitation>;
53
+ listReceivedInvitations(input?: OrganizationPageInput): Promise<ReceivedOrganizationInvitationPage>;
54
+ getReceivedInvitation(invitationId: string): Promise<ReceivedOrganizationInvitation>;
46
55
  getToken(): Promise<DeviceToken | null>;
47
56
  signUpWithEmail(email: string, password: string, name: string): Promise<AuthSession>;
48
57
  signInWithEmail(email: string, password: string): Promise<AuthSession>;
@@ -55,4 +64,4 @@ interface DeviceAuthClient {
55
64
  }
56
65
  declare function createDeviceAuth(options: DeviceAuthOptions): DeviceAuthClient;
57
66
  //#endregion
58
- export { AuthError, type AuthErrorCode, type AuthErrorStatus, DeviceAuthClient, DeviceAuthOptions, DeviceAuthorization, DeviceAuthorizationError, DeviceAuthorizationErrorCode, DeviceToken, DeviceTokenPersistence, PollDeviceOptions, type SocialIdTokenInput, type SocialIdTokenProvider, createDeviceAuth };
67
+ export { AuthError, type AuthErrorCode, type AuthErrorStatus, type AuthOrganization, DeviceAuthClient, DeviceAuthOptions, DeviceAuthorization, DeviceAuthorizationError, DeviceAuthorizationErrorCode, DeviceToken, DeviceTokenPersistence, type OrganizationInvitation, type OrganizationInvitationPage, type OrganizationMember, type OrganizationMemberPage, type OrganizationPage, type OrganizationPageInput, type OrganizationTeam, type OrganizationTeamMemberPage, type OrganizationTeamPage, PollDeviceOptions, type ReceivedOrganizationInvitation, type ReceivedOrganizationInvitationPage, type SocialIdTokenInput, type SocialIdTokenProvider, createDeviceAuth };