Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions packages/darkauth-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ If `tokenStorage: 'localStorage'` or `drkStorage: 'localStorage'` is configured

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`.

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.
Use `{ force: true }` after changing the DarkAuth session organization so the app receives tokens for the newly selected organization even if the current in-memory ID token has not expired.

### Organization Switching

Expand All @@ -132,9 +132,9 @@ Returns the current user's organizations for app-owned switcher UI. Use `status`

Returns current first-party session and organization context for app chrome before a fresh OAuth callback is needed.

#### `switchOrganization(organizationId: string, options?: SwitchOrganizationOptions): Promise<void>`
#### `switchOrganization(organizationId: string, options?: SwitchOrganizationOptions): Promise<AuthSession | null>`

Switches the selected organization. The default `authorize` mode starts a new authorization-code flow. `hosted` mode redirects to DarkAuth's `/switch-org` page.
Switches the selected organization. The default `silent` mode updates the DarkAuth session organization, forces a token refresh, and returns the refreshed session. `authorize` mode starts a new authorization-code flow. `hosted` mode redirects to DarkAuth's `/switch-org` page.

#### App-owned switcher

Expand All @@ -143,7 +143,6 @@ Use this pattern when the app owns the workspace rail, menu, or account switcher
```typescript
import {
getCurrentUser,
handleCallback,
listOrganizations,
switchOrganization,
} from '@DarkAuth/client';
Expand All @@ -152,17 +151,14 @@ const organizations = await listOrganizations();
const activeOrganizationId = getCurrentUser()?.org_id;

async function selectOrganization(organizationId: string) {
await switchOrganization(organizationId, {
mode: 'authorize',
returnTo: window.location.href,
});
const session = await switchOrganization(organizationId);
const selectedOrganizationId = getCurrentUser()?.org_id;
}

const session = await handleCallback();
const selectedOrganizationId = getCurrentUser()?.org_id;
```

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`.
After the refresh, 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`.

Use `mode: 'authorize'` when a deployment should re-enter the redirect-based OAuth flow for every organization switch.

#### Hosted switcher

Expand Down
25 changes: 21 additions & 4 deletions packages/darkauth-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export type InitiateLoginOptions = {
};

export type SwitchOrganizationOptions = {
mode?: "authorize" | "hosted";
mode?: "silent" | "authorize" | "hosted";
returnTo?: string;
};

Expand Down Expand Up @@ -804,10 +804,26 @@ export async function getSessionInfo(): Promise<DarkAuthSessionInfo> {
export async function switchOrganization(
organizationId: string,
options: SwitchOrganizationOptions = {}
): Promise<void> {
if ((options.mode || "authorize") === "authorize") {
): Promise<AuthSession | null> {
const mode = options.mode || "silent";
if (mode === "silent") {
const response = await fetch(rootEndpoint("/api/user/session/organization"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
organization_id: organizationId,
return_to: options.returnTo,
client_id: cfg.clientId,
}),
credentials: fetchCredentials(),
});
if (!response.ok) throw await errorForResponse(response);
await response.json().catch(() => null);
return await refreshSession({ force: true });
}
if (mode === "authorize") {
await initiateLogin({ organizationId, returnTo: options.returnTo });
return;
return null;
}
const switchUrl = new URL(rootEndpoint("/switch-org"));
switchUrl.searchParams.set("organization_id", organizationId);
Expand All @@ -816,6 +832,7 @@ export async function switchOrganization(
switchUrl.searchParams.set("return_to", options.returnTo);
}
location.assign(switchUrl.toString());
return null;
}

export function logout(): void {
Expand Down
55 changes: 53 additions & 2 deletions packages/darkauth-client/tests/initiateLogin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ function createLocation() {
};
}

function token(claims = {}) {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "none", typ: "JWT" })}.${encode({
sub: "user-1",
exp: Math.floor(Date.now() / 1000) + 3600,
...claims,
})}.sig`;
}

test("initiateLogin adds ZK parameters when zk is true", async () => {
setupEnvironment();
const { location, getAssignedUrl } = createLocation();
Expand Down Expand Up @@ -95,7 +104,49 @@ test("initiateLogin includes organization_id when organizationId is supplied", a
assert.equal(url.searchParams.get("organization_id"), "8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
});

test("switchOrganization starts authorize flow by default", async () => {
test("switchOrganization updates the session organization and refreshes by default", async () => {
setupEnvironment();
const { location, getAssignedUrl } = createLocation();
globalThis.location = location;
const idToken = token({ org_id: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff" });
const calls = [];
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), init });
const parsed = new URL(String(url));
if (parsed.pathname === "/api/user/session/organization") {
assert.equal(init.method, "POST");
assert.equal(init.credentials, "include");
const body = JSON.parse(init.body);
assert.equal(body.organization_id, "8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
assert.equal(body.client_id, "client-id");
return { ok: true, json: async () => ({ organizationId: body.organization_id }) };
}
if (parsed.pathname === "/token") {
assert.equal(init.method, "POST");
assert.equal(init.credentials, "include");
const body = new URLSearchParams(init.body);
assert.equal(body.get("grant_type"), "refresh_token");
assert.equal(body.get("client_id"), "client-id");
return { ok: true, json: async () => ({ id_token: idToken }) };
}
throw new Error(`Unexpected fetch ${url}`);
};
setConfig({
issuer: "https://issuer.example",
clientId: "client-id",
redirectUri: "https://app.example/callback",
zk: false,
discovery: false,
});

const session = await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff");

assert.equal(session.idToken, idToken);
assert.equal(getAssignedUrl(), "");
assert.equal(calls.length, 2);
});

test("switchOrganization can start authorize flow", async () => {
setupEnvironment();
const { location, getAssignedUrl } = createLocation();
globalThis.location = location;
Expand All @@ -107,7 +158,7 @@ test("switchOrganization starts authorize flow by default", async () => {
discovery: false,
});

await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff", { mode: "authorize" });

const url = new URL(getAssignedUrl());
assert.equal(url.pathname, "/authorize");
Expand Down
16 changes: 6 additions & 10 deletions packages/docs/src/content/docs/developers/sdk/typescript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ Use this pattern when the app renders its own workspace rail, account menu, or o
```typescript
import {
getCurrentUser,
handleCallback,
listOrganizations,
switchOrganization,
} from "@DarkAuth/client";
Expand All @@ -66,17 +65,14 @@ const organizations = await listOrganizations();
const activeOrganizationId = getCurrentUser()?.org_id;

async function chooseOrganization(organizationId: string) {
await switchOrganization(organizationId, {
mode: "authorize",
returnTo: window.location.href,
});
const session = await switchOrganization(organizationId);
const selectedOrganizationId = getCurrentUser()?.org_id;
}

await handleCallback();
const selectedOrganizationId = getCurrentUser()?.org_id;
```

App-owned switching uses a normal authorization-code flow with PKCE and state. DarkAuth validates active membership before issuing a code. After the callback, verify the token `org_id` before loading workspace data.
App-owned switching updates the DarkAuth session organization, forces a token refresh, and returns the refreshed org-scoped session. DarkAuth validates active membership before changing the session. After the refresh, verify the token `org_id` before loading workspace data.

Use `mode: "authorize"` when a deployment should re-enter the redirect-based OAuth flow with PKCE and state for every organization switch.

Treat every organization switch as a tenant or workspace state reset. Clear tenant-local caches, selected records, in-flight requests, realtime subscriptions, and cached authorization decisions before rendering data for the new organization.

Expand Down Expand Up @@ -105,7 +101,7 @@ Send `organization_id` on `/authorize` when the app already knows the intended o
- A workspace URL or subdomain maps to a known organization.
- The app is starting login from an organization-specific invite or deep link.

With the SDK, call `initiateLogin({ organizationId })` or `switchOrganization(organizationId)`. Without the SDK, include `organization_id=<uuid>` in the authorization request. Omit it when DarkAuth should select the user's only active organization or show the hosted selector for multi-organization users.
With the SDK, call `initiateLogin({ organizationId })` to send `organization_id` on `/authorize`. For an already signed-in first-party browser app, call `switchOrganization(organizationId)` to silently update the session organization and refresh tokens. Without the SDK, include `organization_id=<uuid>` in the authorization request. Omit it when DarkAuth should select the user's only active organization or show the hosted selector for multi-organization users.

### Selected organization claims

Expand Down
51 changes: 38 additions & 13 deletions specs/ORG_SWITCHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The client does not currently expose typed organization helpers, and `initiateLo
## Goals

- Let apps render their own organization switcher while DarkAuth remains the source of truth.
- Let apps request a fresh token for a selected organization without full logout.
- Let apps request a fresh token for a selected organization without full logout or repeat consent when the app is already authorized.
- Keep the active organization represented by normal OAuth/OIDC token claims.
- Preserve hosted DarkAuth org selection for apps that do not build their own switch UI.
- Make SDK APIs simple enough for Atlas-style tenant switching.
Expand All @@ -47,23 +47,32 @@ The client does not currently expose typed organization helpers, and `initiateLo
- Letting apps mutate DarkAuth organization membership through the auth SDK.
- Replacing the hosted `/switch-org` page.
- Supporting arbitrary third-party cross-origin session mutation without a browser redirect or proper OAuth flow.
- Bypassing consent, PKCE, client redirect validation, or membership validation.
- Bypassing first-time consent, PKCE, client redirect validation, or membership validation.

## Standard Provider Model

DarkAuth should treat organization switching as selecting a new authorization context. The selected organization is not just UI state. It must be reflected in freshly issued tokens.

Recommended app-owned flow:
Recommended app-owned flow for first-party browser apps with an existing DarkAuth session:

1. App asks DarkAuth for active organizations.
2. User selects an organization inside the app.
3. App starts a new authorization request with `organization_id=<selected org>`.
4. DarkAuth validates the user's active membership.
5. DarkAuth returns an authorization code to the registered app callback.
6. App handles the callback and receives a new org-scoped session/token.
3. App calls `switchOrganization(<selected org>)`.
4. DarkAuth validates the user's active membership and updates the first-party session organization.
5. The SDK forces a token refresh.
6. App receives a new org-scoped session/token.
7. App clears tenant-local state and loads data for the selected org.

This is the safest default for third-party relying parties because it stays inside OAuth redirect, PKCE, state, redirect URI, and token issuance rules.
Authorize fallback flow:

1. App starts a new authorization request with `organization_id=<selected org>`.
2. DarkAuth validates the user's active membership.
3. DarkAuth skips repeat consent when the client, user, organization, and requested scopes are already authorized.
4. DarkAuth returns an authorization code to the registered app callback.
5. App handles the callback and receives a new org-scoped session/token.
6. App clears tenant-local state and loads data for the selected org.

The authorize fallback is safest for stricter third-party deployments because it stays inside OAuth redirect, PKCE, state, redirect URI, and token issuance rules.

Hosted fallback flow:

Expand Down Expand Up @@ -134,7 +143,7 @@ Keep:
POST /api/user/session/organization
```

This endpoint is appropriate for same-origin hosted DarkAuth UI. It is not enough as the only app-owned third-party integration because it is CSRF protected and relies on DarkAuth first-party cookies.
This endpoint is appropriate for hosted DarkAuth UI and trusted browser SDK usage where the DarkAuth first-party session cookie is available. It is not enough as the only integration for stricter third-party relying parties because it relies on DarkAuth first-party cookies.

Rules:

Expand Down Expand Up @@ -186,7 +195,7 @@ export type InitiateLoginOptions = {
};

export type SwitchOrganizationOptions = {
mode?: "authorize" | "hosted";
mode?: "silent" | "authorize" | "hosted";
returnTo?: string;
};
```
Expand Down Expand Up @@ -241,14 +250,22 @@ Add:
export async function switchOrganization(
organizationId: string,
options?: SwitchOrganizationOptions
): Promise<void>
): Promise<AuthSession | null>
```

Default behavior should be `mode: "authorize"` for third-party apps:
Default behavior should be `mode: "silent"`:

- Call `POST /api/user/session/organization`.
- Force `refreshSession({ force: true })`.
- Return the refreshed org-scoped session.
- This avoids repeat consent when the browser already has a valid DarkAuth session and the client remains authorized for the requested scopes.

Authorize behavior:

- Call `initiateLogin({ organizationId, returnTo })`.
- This produces a normal authorization-code flow and fresh org-scoped token.
- The app handles the callback with existing `handleCallback()`.
- Use `mode: "authorize"` when a deployment wants redirect-based OAuth controls for every switch.

Hosted behavior:

Expand Down Expand Up @@ -296,6 +313,11 @@ For app-owned Slack-style switching:
- App shows organization rail using `listOrganizations()`.
- Active item is derived from current token `org_id`.
- Clicking another org calls `switchOrganization(orgId)`.
- App validates the refreshed token's `org_id`, clears tenant-local state, and reloads.

For authorize-based switching:

- App calls `switchOrganization(orgId, { mode: "authorize" })`.
- App handles callback, validates new `org_id`, clears tenant-local state, and reloads.

For hosted switching:
Expand All @@ -316,6 +338,7 @@ For login:
- Keep authorization-code flow protected by PKCE and state.
- Validate redirect URI and `return_to` exactly as today.
- Do not expose session organization mutation as an unprotected cross-origin API.
- Do not show repeat consent when the user has already approved the same client and scope set unless the client explicitly requires it.
- Do not include roles or permissions from non-selected orgs.
- Audit org switching through hosted session changes and authorize-time changes.
- Keep `ORG_CONTEXT_REQUIRED` machine-readable for developers but map it to user-facing org selection.
Expand All @@ -324,7 +347,7 @@ For login:

- An app can list the signed-in user's organizations through `@darkauth/client`.
- An app can start login for a specific organization through `@darkauth/client`.
- An app can switch organizations through `@darkauth/client` and receive a fresh token with the selected `org_id`.
- An app can switch organizations through `@darkauth/client` and receive a fresh token with the selected `org_id` without a repeat authorize/consent screen when the DarkAuth session can be used silently.
- The existing hosted `/switch-org` flow remains available and documented.
- Refreshing after a hosted switch returns tokens for the new session organization.
- Tokens contain roles and permissions only for the selected organization.
Expand All @@ -349,6 +372,7 @@ For login:
- [x] Add `listOrganizations()`.
- [x] Add `getSessionInfo()`.
- [x] Add `switchOrganization(organizationId, options?)`.
- [x] Default `switchOrganization(organizationId)` to session-org switch plus forced refresh.
- [x] Add `refreshSession({ force })`.
- [x] Add typed errors for unauthenticated session, invalid org, and org context required.
- [x] Update SDK README examples for app-owned and hosted org switching.
Expand All @@ -365,6 +389,7 @@ For login:

- [x] SDK test for `initiateLogin({ organizationId })` authorization URL.
- [x] SDK test for `listOrganizations()`.
- [x] SDK test for `switchOrganization()` silent mode.
- [x] SDK test for `switchOrganization()` authorize mode.
- [x] SDK test for hosted switch URL generation.
- [x] SDK test for `refreshSession({ force: true })`.
Expand Down
Loading