@darkauth/client 1.17.0 → 1.18.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
@@ -13,6 +13,7 @@ The client supports both:
13
13
  - **Token Management**: First-party cookie refresh by default, with optional legacy token storage
14
14
  - **Data Encryption Keys (DEK)**: Support for deriving and managing data encryption keys
15
15
  - **DRK Custody**: Memory-only DRK handling by default for hosted web zero-knowledge apps
16
+ - **Organization Switching**: App-owned and hosted organization selection flows for tenant-scoped apps
16
17
  - **TypeScript Support**: Full TypeScript definitions included
17
18
 
18
19
  ## Installation
@@ -82,10 +83,12 @@ The client also supports environment variables for configuration:
82
83
 
83
84
  ### Authentication Functions
84
85
 
85
- #### `initiateLogin(): Promise<void>`
86
+ #### `initiateLogin(options?: InitiateLoginOptions): Promise<void>`
86
87
 
87
88
  Starts the OAuth2/OIDC login flow with PKCE. Redirects the user to the DarkAuth authorization server.
88
89
 
90
+ Pass `organizationId` when the app already knows which organization the user wants to enter. The SDK sends it as `organization_id` on `/authorize`, and DarkAuth validates active membership before issuing a code. Omit it when the app wants DarkAuth to select the only active organization or show the hosted organization selector for multi-organization users.
91
+
89
92
  #### `handleCallback(): Promise<AuthSession | null>`
90
93
 
91
94
  Processes the OAuth callback after successful authentication. Returns an `AuthSession` object containing:
@@ -111,10 +114,84 @@ Retrieves the current in-memory session if valid. For non-ZK sessions, returns `
111
114
 
112
115
  If `tokenStorage: 'localStorage'` or `drkStorage: 'localStorage'` is configured for a legacy app, this function can also restore those explicitly persisted values.
113
116
 
114
- #### `refreshSession(): Promise<AuthSession | null>`
117
+ #### `refreshSession(options?: { force?: boolean }): Promise<AuthSession | null>`
115
118
 
116
119
  Refreshes the current session. In default first-party mode, the browser sends the DarkAuth refresh cookie and no JavaScript-readable refresh token is required. For non-ZK sessions, returns `drk` as an empty `Uint8Array`.
117
120
 
121
+ Use `{ force: true }` after a hosted organization switch so the app receives tokens for the newly selected DarkAuth session organization even if the current in-memory ID token has not expired.
122
+
123
+ ### Organization Switching
124
+
125
+ DarkAuth treats organization switching as choosing a new authorization context. Tokens are scoped to one selected organization at a time. Apps must not merge roles or permissions across organizations.
126
+
127
+ #### `listOrganizations(): Promise<DarkAuthOrganization[]>`
128
+
129
+ Returns the current user's organizations for app-owned switcher UI. Use `status` to decide which memberships are selectable.
130
+
131
+ #### `getSessionInfo(): Promise<{ authenticated: boolean; sub?: string; email?: string | null; name?: string | null; organizationId?: string; organizationSlug?: string | null }>`
132
+
133
+ Returns current first-party session and organization context for app chrome before a fresh OAuth callback is needed.
134
+
135
+ #### `switchOrganization(organizationId: string, options?: SwitchOrganizationOptions): Promise<void>`
136
+
137
+ Switches the selected organization. The default `authorize` mode starts a new authorization-code flow. `hosted` mode redirects to DarkAuth's `/switch-org` page.
138
+
139
+ #### App-owned switcher
140
+
141
+ Use this pattern when the app owns the workspace rail, menu, or account switcher UI.
142
+
143
+ ```typescript
144
+ import {
145
+ getCurrentUser,
146
+ handleCallback,
147
+ listOrganizations,
148
+ switchOrganization,
149
+ } from '@DarkAuth/client';
150
+
151
+ const organizations = await listOrganizations();
152
+ const activeOrganizationId = getCurrentUser()?.org_id;
153
+
154
+ async function selectOrganization(organizationId: string) {
155
+ await switchOrganization(organizationId, {
156
+ mode: 'authorize',
157
+ returnTo: window.location.href,
158
+ });
159
+ }
160
+
161
+ const session = await handleCallback();
162
+ const selectedOrganizationId = getCurrentUser()?.org_id;
163
+ ```
164
+
165
+ After the callback, verify that `selectedOrganizationId` matches the workspace being loaded. Treat the switch as a tenant or workspace state reset: clear tenant-local caches, selected resources, open realtime subscriptions, in-flight requests, and authorization decisions before loading data for the new `org_id`.
166
+
167
+ #### Hosted switcher
168
+
169
+ Use this pattern when DarkAuth should own the organization picker UI.
170
+
171
+ ```typescript
172
+ import { refreshSession, switchOrganization } from '@DarkAuth/client';
173
+
174
+ await switchOrganization('org_123', {
175
+ mode: 'hosted',
176
+ returnTo: window.location.href,
177
+ });
178
+
179
+ const session = await refreshSession({ force: true });
180
+ ```
181
+
182
+ Hosted mode redirects to DarkAuth's `/switch-org` page. DarkAuth updates the first-party session organization and returns to the app. The app then forces a refresh so the ID and access tokens reflect the selected organization.
183
+
184
+ #### Token claims
185
+
186
+ When organization context is resolved, ID and access tokens can include:
187
+
188
+ - `org_id`: selected organization ID.
189
+ - `org_slug`: selected organization slug.
190
+ - `roles`: roles for the selected organization only.
191
+ - `permissions`: permissions for the selected organization only.
192
+
193
+ Use `sub` for the user identity and `org_id` for the active tenant or workspace. A user can have different roles in different organizations, so apps must authorize each request against the token's selected `org_id` and must reject resource access for a different organization.
194
+
118
195
  ### User Information
119
196
 
120
197
  #### `getCurrentUser(): JwtClaims | null`
@@ -208,6 +285,10 @@ interface JwtClaims {
208
285
  exp?: number;
209
286
  iat?: number;
210
287
  iss?: string;
288
+ org_id?: string;
289
+ org_slug?: string;
290
+ roles?: string[];
291
+ permissions?: string[];
211
292
  }
212
293
  ```
213
294
 
@@ -226,6 +307,33 @@ type Config = {
226
307
  }
227
308
  ```
228
309
 
310
+ ### `DarkAuthOrganization`
311
+ ```typescript
312
+ type DarkAuthOrganization = {
313
+ organizationId: string;
314
+ slug: string;
315
+ name: string;
316
+ status: string;
317
+ roles?: Array<{ id: string; key: string; name: string }>;
318
+ }
319
+ ```
320
+
321
+ ### `InitiateLoginOptions`
322
+ ```typescript
323
+ type InitiateLoginOptions = {
324
+ organizationId?: string;
325
+ returnTo?: string;
326
+ }
327
+ ```
328
+
329
+ ### `SwitchOrganizationOptions`
330
+ ```typescript
331
+ type SwitchOrganizationOptions = {
332
+ mode?: 'authorize' | 'hosted';
333
+ returnTo?: string;
334
+ }
335
+ ```
336
+
229
337
  ### `ClientHooks`
230
338
  ```typescript
231
339
  type ClientHooks = {
package/dist/index.d.ts CHANGED
@@ -33,13 +33,65 @@ export interface JwtClaims {
33
33
  exp?: number;
34
34
  iat?: number;
35
35
  iss?: string;
36
+ org_id?: string;
37
+ org_slug?: string;
38
+ roles?: string[];
39
+ permissions?: string[];
40
+ }
41
+ export type DarkAuthOrganization = {
42
+ organizationId: string;
43
+ slug: string;
44
+ name: string;
45
+ status: string;
46
+ roles?: Array<{
47
+ id: string;
48
+ key: string;
49
+ name: string;
50
+ }>;
51
+ };
52
+ export type InitiateLoginOptions = {
53
+ organizationId?: string;
54
+ returnTo?: string;
55
+ };
56
+ export type SwitchOrganizationOptions = {
57
+ mode?: "authorize" | "hosted";
58
+ returnTo?: string;
59
+ };
60
+ export type RefreshSessionOptions = {
61
+ force?: boolean;
62
+ };
63
+ export type DarkAuthSessionInfo = {
64
+ authenticated: boolean;
65
+ sub?: string;
66
+ email?: string | null;
67
+ name?: string | null;
68
+ organizationId?: string;
69
+ organizationSlug?: string | null;
70
+ };
71
+ export type DarkAuthErrorCode = "unauthenticated_session" | "invalid_organization" | "org_context_required" | "request_failed";
72
+ export declare class DarkAuthError extends Error {
73
+ code: DarkAuthErrorCode;
74
+ status?: number;
75
+ constructor(message: string, code: DarkAuthErrorCode, status?: number);
76
+ }
77
+ export declare class UnauthenticatedSessionError extends DarkAuthError {
78
+ constructor(message?: string, status?: number);
79
+ }
80
+ export declare class InvalidOrganizationError extends DarkAuthError {
81
+ constructor(message?: string, status?: number);
82
+ }
83
+ export declare class OrgContextRequiredError extends DarkAuthError {
84
+ constructor(message?: string, status?: number);
36
85
  }
37
86
  export declare function setConfig(next: Partial<Config>): void;
38
87
  export declare function parseJwt(token: string): JwtClaims | null;
39
88
  export declare function isTokenValid(token: string): boolean;
40
- export declare function initiateLogin(): Promise<void>;
89
+ export declare function initiateLogin(options?: InitiateLoginOptions): Promise<void>;
41
90
  export declare function handleCallback(): Promise<AuthSession | null>;
42
91
  export declare function getStoredSession(): AuthSession | null;
43
- export declare function refreshSession(): Promise<AuthSession | null>;
92
+ export declare function refreshSession(options?: RefreshSessionOptions): Promise<AuthSession | null>;
93
+ export declare function listOrganizations(): Promise<DarkAuthOrganization[]>;
94
+ export declare function getSessionInfo(): Promise<DarkAuthSessionInfo>;
95
+ export declare function switchOrganization(organizationId: string, options?: SwitchOrganizationOptions): Promise<void>;
44
96
  export declare function logout(): void;
45
97
  export declare function getCurrentUser(): JwtClaims | null;
package/dist/index.js CHANGED
@@ -2,6 +2,34 @@ import { compactDecrypt } from "jose";
2
2
  export * from "./crypto.js";
3
3
  export * from "./dek.js";
4
4
  export { setHooks } from "./hooks.js";
5
+ export class DarkAuthError extends Error {
6
+ code;
7
+ status;
8
+ constructor(message, code, status) {
9
+ super(message);
10
+ this.name = "DarkAuthError";
11
+ this.code = code;
12
+ this.status = status;
13
+ }
14
+ }
15
+ export class UnauthenticatedSessionError extends DarkAuthError {
16
+ constructor(message = "User session required", status = 401) {
17
+ super(message, "unauthenticated_session", status);
18
+ this.name = "UnauthenticatedSessionError";
19
+ }
20
+ }
21
+ export class InvalidOrganizationError extends DarkAuthError {
22
+ constructor(message = "Invalid organization", status = 403) {
23
+ super(message, "invalid_organization", status);
24
+ this.name = "InvalidOrganizationError";
25
+ }
26
+ }
27
+ export class OrgContextRequiredError extends DarkAuthError {
28
+ constructor(message = "Organization context required", status = 400) {
29
+ super(message, "org_context_required", status);
30
+ this.name = "OrgContextRequiredError";
31
+ }
32
+ }
5
33
  function viteEnvGet(key) {
6
34
  try {
7
35
  const im = import.meta || undefined;
@@ -75,6 +103,52 @@ function fetchCredentials() {
75
103
  function rootEndpoint(path) {
76
104
  return new URL(path, cfg.issuer).toString();
77
105
  }
106
+ function isSafeReturnTo(returnTo) {
107
+ if (returnTo.startsWith("/")) {
108
+ return !returnTo.startsWith("//") && !returnTo.includes("\\");
109
+ }
110
+ try {
111
+ const url = new URL(returnTo);
112
+ return url.protocol === "http:" || url.protocol === "https:";
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ function requestFailed(status, message = "DarkAuth request failed") {
119
+ return new DarkAuthError(message, "request_failed", status);
120
+ }
121
+ async function readErrorPayload(response) {
122
+ try {
123
+ const data = (await response.json());
124
+ if (data && typeof data === "object" && !Array.isArray(data)) {
125
+ return data;
126
+ }
127
+ }
128
+ catch { }
129
+ return null;
130
+ }
131
+ async function errorForResponse(response) {
132
+ const payload = await readErrorPayload(response);
133
+ const code = typeof payload?.code === "string"
134
+ ? payload.code
135
+ : typeof payload?.error === "string"
136
+ ? payload.error
137
+ : undefined;
138
+ const message = typeof payload?.message === "string"
139
+ ? payload.message
140
+ : typeof payload?.error_description === "string"
141
+ ? payload.error_description
142
+ : undefined;
143
+ if (response.status === 401)
144
+ return new UnauthenticatedSessionError(message, response.status);
145
+ if (code === "ORG_CONTEXT_REQUIRED" || code === "org_context_required") {
146
+ return new OrgContextRequiredError(message, response.status);
147
+ }
148
+ if (response.status === 403)
149
+ return new InvalidOrganizationError(message, response.status);
150
+ return requestFailed(response.status, message);
151
+ }
78
152
  async function resolveEndpoints() {
79
153
  const cacheKey = [
80
154
  cfg.issuer,
@@ -265,7 +339,7 @@ export function isTokenValid(token) {
265
339
  return false;
266
340
  return claims.exp * 1000 > Date.now() + 5000;
267
341
  }
268
- export async function initiateLogin() {
342
+ export async function initiateLogin(options = {}) {
269
343
  const zkEnabled = cfg.zk !== false;
270
344
  let zkPubParam;
271
345
  if (zkEnabled) {
@@ -294,6 +368,8 @@ export async function initiateLogin() {
294
368
  authUrl.searchParams.set("code_challenge_method", "S256");
295
369
  if (zkEnabled && zkPubParam)
296
370
  authUrl.searchParams.set("zk_pub", zkPubParam);
371
+ if (options.organizationId)
372
+ authUrl.searchParams.set("organization_id", options.organizationId);
297
373
  location.assign(authUrl.toString());
298
374
  }
299
375
  export async function handleCallback() {
@@ -503,7 +579,12 @@ export function getStoredSession() {
503
579
  refreshToken: refreshMode() === "token" ? localStorage.getItem(REFRESH_TOKEN_KEY) || undefined : undefined,
504
580
  };
505
581
  }
506
- export async function refreshSession() {
582
+ export async function refreshSession(options = {}) {
583
+ if (!options.force) {
584
+ const current = getStoredSession();
585
+ if (current)
586
+ return current;
587
+ }
507
588
  const currentRefreshMode = refreshMode();
508
589
  const refreshToken = currentRefreshMode === "token"
509
590
  ? memoryRefreshToken || localStorage.getItem(REFRESH_TOKEN_KEY)
@@ -526,6 +607,9 @@ export async function refreshSession() {
526
607
  credentials: fetchCredentials(),
527
608
  });
528
609
  if (!response.ok) {
610
+ const error = await errorForResponse(response);
611
+ if (error instanceof OrgContextRequiredError)
612
+ throw error;
529
613
  if (response.status === 401) {
530
614
  if (currentRefreshMode === "token") {
531
615
  const latestRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
@@ -562,6 +646,58 @@ export async function refreshSession() {
562
646
  refreshToken: currentRefreshMode === "token" ? newRefreshToken || refreshToken || undefined : undefined,
563
647
  });
564
648
  }
649
+ export async function listOrganizations() {
650
+ const response = await fetch(rootEndpoint("/api/user/organizations"), {
651
+ credentials: fetchCredentials(),
652
+ });
653
+ if (!response.ok)
654
+ throw await errorForResponse(response);
655
+ const data = (await response.json());
656
+ if (!Array.isArray(data.organizations))
657
+ return [];
658
+ return data.organizations.filter((org) => {
659
+ if (!org || typeof org !== "object")
660
+ return false;
661
+ const candidate = org;
662
+ return (typeof candidate.organizationId === "string" &&
663
+ typeof candidate.slug === "string" &&
664
+ typeof candidate.name === "string" &&
665
+ typeof candidate.status === "string");
666
+ });
667
+ }
668
+ export async function getSessionInfo() {
669
+ const response = await fetch(rootEndpoint("/api/user/session"), {
670
+ credentials: fetchCredentials(),
671
+ });
672
+ if (response.status === 401)
673
+ return { authenticated: false };
674
+ if (!response.ok)
675
+ throw await errorForResponse(response);
676
+ const data = (await response.json());
677
+ return {
678
+ authenticated: data.authenticated === true,
679
+ sub: typeof data.sub === "string" ? data.sub : undefined,
680
+ email: typeof data.email === "string" || data.email === null ? data.email : undefined,
681
+ name: typeof data.name === "string" || data.name === null ? data.name : undefined,
682
+ organizationId: typeof data.organizationId === "string" ? data.organizationId : undefined,
683
+ organizationSlug: typeof data.organizationSlug === "string" || data.organizationSlug === null
684
+ ? data.organizationSlug
685
+ : undefined,
686
+ };
687
+ }
688
+ export async function switchOrganization(organizationId, options = {}) {
689
+ if ((options.mode || "authorize") === "authorize") {
690
+ await initiateLogin({ organizationId, returnTo: options.returnTo });
691
+ return;
692
+ }
693
+ const switchUrl = new URL(rootEndpoint("/switch-org"));
694
+ switchUrl.searchParams.set("organization_id", organizationId);
695
+ switchUrl.searchParams.set("client_id", cfg.clientId);
696
+ if (options.returnTo && isSafeReturnTo(options.returnTo)) {
697
+ switchUrl.searchParams.set("return_to", options.returnTo);
698
+ }
699
+ location.assign(switchUrl.toString());
700
+ }
565
701
  export function logout() {
566
702
  memorySession = null;
567
703
  memoryRefreshToken = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darkauth/client",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "directory": "packages/darkauth-client",