diff --git a/packages/darkauth-client/README.md b/packages/darkauth-client/README.md index 2576357..d4c04cd 100644 --- a/packages/darkauth-client/README.md +++ b/packages/darkauth-client/README.md @@ -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 @@ -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` +#### `switchOrganization(organizationId: string, options?: SwitchOrganizationOptions): Promise` -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 @@ -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'; @@ -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 diff --git a/packages/darkauth-client/src/index.ts b/packages/darkauth-client/src/index.ts index 553dd2a..256a3be 100644 --- a/packages/darkauth-client/src/index.ts +++ b/packages/darkauth-client/src/index.ts @@ -58,7 +58,7 @@ export type InitiateLoginOptions = { }; export type SwitchOrganizationOptions = { - mode?: "authorize" | "hosted"; + mode?: "silent" | "authorize" | "hosted"; returnTo?: string; }; @@ -804,10 +804,26 @@ export async function getSessionInfo(): Promise { export async function switchOrganization( organizationId: string, options: SwitchOrganizationOptions = {} -): Promise { - if ((options.mode || "authorize") === "authorize") { +): Promise { + 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); @@ -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 { diff --git a/packages/darkauth-client/tests/initiateLogin.test.js b/packages/darkauth-client/tests/initiateLogin.test.js index a304b9f..64e1c63 100644 --- a/packages/darkauth-client/tests/initiateLogin.test.js +++ b/packages/darkauth-client/tests/initiateLogin.test.js @@ -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(); @@ -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; @@ -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"); diff --git a/packages/docs/src/content/docs/developers/sdk/typescript.mdx b/packages/docs/src/content/docs/developers/sdk/typescript.mdx index 3d3ec0a..0459c34 100644 --- a/packages/docs/src/content/docs/developers/sdk/typescript.mdx +++ b/packages/docs/src/content/docs/developers/sdk/typescript.mdx @@ -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"; @@ -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. @@ -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=` 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=` 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 diff --git a/specs/ORG_SWITCHING.md b/specs/ORG_SWITCHING.md index f8124d0..fcd228e 100644 --- a/specs/ORG_SWITCHING.md +++ b/specs/ORG_SWITCHING.md @@ -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. @@ -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=`. -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()`. +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=`. +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: @@ -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: @@ -186,7 +195,7 @@ export type InitiateLoginOptions = { }; export type SwitchOrganizationOptions = { - mode?: "authorize" | "hosted"; + mode?: "silent" | "authorize" | "hosted"; returnTo?: string; }; ``` @@ -241,14 +250,22 @@ Add: export async function switchOrganization( organizationId: string, options?: SwitchOrganizationOptions -): Promise +): Promise ``` -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: @@ -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: @@ -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. @@ -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. @@ -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. @@ -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 })`.