diff --git a/packages/api/src/controllers/user/authorize.ts b/packages/api/src/controllers/user/authorize.ts index 5072605..fccf41f 100644 --- a/packages/api/src/controllers/user/authorize.ts +++ b/packages/api/src/controllers/user/authorize.ts @@ -19,7 +19,12 @@ import { resolveGrantedScopes } from "./token.ts"; function parseScopeSet(scope: unknown): Set { if (typeof scope !== "string") return new Set(); - return new Set(scope.split(/\s+/).map((item) => item.trim()).filter(Boolean)); + return new Set( + scope + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean) + ); } function sessionCoversAuthorization( diff --git a/packages/api/src/controllers/user/oauthEndpoints.test.ts b/packages/api/src/controllers/user/oauthEndpoints.test.ts index ea902aa..6090a99 100644 --- a/packages/api/src/controllers/user/oauthEndpoints.test.ts +++ b/packages/api/src/controllers/user/oauthEndpoints.test.ts @@ -7,7 +7,7 @@ import { Readable } from "node:stream"; import { test } from "node:test"; import { createPglite } from "../../db/pglite.ts"; import { authCodes, clients, organizationMembers, organizations, users } from "../../db/schema.ts"; -import { InvalidGrantError, UnauthorizedClientError } from "../../errors.ts"; +import { ForbiddenError, InvalidGrantError, UnauthorizedClientError } from "../../errors.ts"; import { generateEdDSAKeyPair, signJWT, storeKeyPair, verifyJWT } from "../../services/jwks.ts"; import { createSession, @@ -17,6 +17,7 @@ import { import type { Context } from "../../types.ts"; import { sha256Base64Url } from "../../utils/crypto.ts"; import { postIntrospect } from "./introspect.ts"; +import { getOrganizations } from "./organizations.ts"; import { postRevoke } from "./revoke.ts"; import { postToken, postTokenOrganization } from "./token.ts"; import { handleUserinfo } from "./userinfo.ts"; @@ -498,13 +499,16 @@ test("token organization switch mints target-org tokens from current app token", await postTokenOrganization(context, request, response); const json = response.json as Record; - const idTokenClaims = await verifyJWT(context, json.id_token as string, "public-refresh-client"); + const idTokenClaims = await verifyJWT( + context, + json.id_token as string, + "public-refresh-client" + ); const accessTokenClaims = await verifyJWT( context, json.access_token as string, "public-refresh-client" ); - const setCookie = response.getHeader("set-cookie"); assert.equal(response.statusCode, 200); assert.equal(json.token_type, "Bearer"); assert.equal(json.scope, "openid profile"); @@ -512,8 +516,81 @@ test("token organization switch mints target-org tokens from current app token", assert.equal(idTokenClaims.org_id, targetOrganization.id); assert.equal(idTokenClaims.org_slug, "second-org"); assert.equal(accessTokenClaims.org_id, targetOrganization.id); - assert.ok(Array.isArray(setCookie)); - assert.ok(setCookie.some((value) => value.includes(USER_REFRESH_COOKIE_NAME))); + assert.equal(response.getHeader("set-cookie"), undefined); + } finally { + await cleanup(); + } +}); + +test("token organization switch rejects ID tokens", async () => { + const { context, cleanup } = await createContext(); + try { + await createUser(context); + const targetOrganization = await createUserOrganization(context); + await createPublicRefreshClient(context); + const idToken = await signJWT( + context, + { + iss: context.config.issuer, + sub: "user-sub", + aud: "public-refresh-client", + }, + "5m" + ); + const request = createRequest({ + method: "POST", + url: "/token/organization", + authorization: `Bearer ${idToken}`, + body: JSON.stringify({ + organization_id: targetOrganization.id, + client_id: "public-refresh-client", + }), + }); + + await assert.rejects( + () => postTokenOrganization(context, request, createResponse()), + (error) => error instanceof ForbiddenError && error.message === "Access token required" + ); + } finally { + await cleanup(); + } +}); + +test("organizations list accepts current app access token without session cookie", async () => { + const { context, cleanup } = await createContext(); + try { + await createUser(context); + await createUserOrganization(context); + await createPublicRefreshClient(context); + const accessToken = await signJWT( + context, + { + iss: context.config.issuer, + sub: "user-sub", + aud: "public-refresh-client", + azp: "public-refresh-client", + scope: "openid profile", + token_use: "access", + grant_type: "authorization_code", + }, + "5m" + ); + const request = createRequest({ + method: "GET", + url: "/organizations", + authorization: `Bearer ${accessToken}`, + }); + const response = createResponse(); + + await getOrganizations(context, request, response); + + const json = response.json as { + organizations: Array<{ organizationId: string; slug: string; status: string }>; + }; + assert.equal(response.statusCode, 200); + assert.equal(json.organizations.length, 1); + assert.equal(json.organizations[0]?.slug, "test-org"); + assert.equal(json.organizations[0]?.status, "active"); } finally { await cleanup(); } diff --git a/packages/api/src/controllers/user/organizations.ts b/packages/api/src/controllers/user/organizations.ts index c46e7e1..b021514 100644 --- a/packages/api/src/controllers/user/organizations.ts +++ b/packages/api/src/controllers/user/organizations.ts @@ -1,6 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { z } from "zod/v4"; +import { ForbiddenError, UnauthorizedClientError, UnauthorizedError } from "../../errors.ts"; import { genericErrors } from "../../http/openapi-helpers.ts"; +import { getClient } from "../../models/clients.ts"; import { assignMemberRoles, createOrganization, @@ -14,6 +16,7 @@ import { removeOrganizationMember, requireOrganizationMembership, } from "../../models/organizations.ts"; +import { verifyJWT } from "../../services/jwks.ts"; import { requireSession } from "../../services/sessions.ts"; import type { Context, ControllerSchema } from "../../types.ts"; import { withAudit } from "../../utils/auditWrapper.ts"; @@ -58,13 +61,55 @@ const AssignableRoleSchema = z.object({ description: z.string().nullable().optional(), }); +function getBearerToken(request: IncomingMessage): string | null { + const auth = request.headers.authorization; + if (typeof auth !== "string") return null; + const [scheme, token] = auth.split(" "); + if (!scheme || scheme.toLowerCase() !== "bearer" || !token) { + throw new UnauthorizedError("Bearer token required"); + } + return token; +} + +function resolveTokenClientId(payload: import("jose").JWTPayload): string { + if (typeof payload.azp === "string" && payload.azp.length > 0) return payload.azp; + if (typeof payload.aud === "string" && payload.aud.length > 0) return payload.aud; + if (Array.isArray(payload.aud)) { + const clientId = payload.aud.find((audience) => typeof audience === "string"); + if (typeof clientId === "string" && clientId.length > 0) return clientId; + } + throw new ForbiddenError("Token was not issued to a known client"); +} + +async function getOrganizationsUserSub(context: Context, request: IncomingMessage) { + const token = getBearerToken(request); + if (!token) { + const session = await requireSession(context, request, false); + return session.sub as string; + } + let payload: import("jose").JWTPayload; + try { + payload = await verifyJWT(context, token); + } catch { + throw new UnauthorizedError("Invalid bearer token"); + } + if (payload.token_use !== "access") throw new ForbiddenError("Access token required"); + if (payload.grant_type === "client_credentials") throw new ForbiddenError("User token required"); + if (typeof payload.sub !== "string" || payload.sub.length === 0) { + throw new UnauthorizedError("User token required"); + } + const client = await getClient(context, resolveTokenClientId(payload)); + if (!client) throw new UnauthorizedClientError("Unknown client"); + return payload.sub; +} + export async function getOrganizations( context: Context, request: IncomingMessage, response: ServerResponse ) { - const session = await requireSession(context, request, false); - const organizations = await listOrganizationsForUser(context, session.sub as string); + const userSub = await getOrganizationsUserSub(context, request); + const organizations = await listOrganizationsForUser(context, userSub); sendJson(response, 200, { organizations }); } @@ -277,7 +322,7 @@ export const organizationsSchema = { tags: ["Organizations"], summary: "List organizations", description: - "Lists the current user's active organization memberships with role summaries for app switcher UIs.", + "Lists the current user's active organization memberships with role summaries for app switcher UIs. Accepts either a first-party session cookie or a current app access token in the Authorization header.", responses: { 200: { description: "OK", diff --git a/packages/api/src/controllers/user/token.ts b/packages/api/src/controllers/user/token.ts index a61ea1c..e14f27f 100644 --- a/packages/api/src/controllers/user/token.ts +++ b/packages/api/src/controllers/user/token.ts @@ -191,6 +191,9 @@ function tokenAudienceMatches(audience: unknown, clientId: string): boolean { } function assertTokenIssuedToClient(payload: import("jose").JWTPayload, clientId: string): void { + if (payload.token_use !== "access") { + throw new ForbiddenError("Access token required"); + } if (typeof payload.azp === "string" && payload.azp !== clientId) { throw new ForbiddenError("Token was not issued to this client"); } @@ -314,9 +317,7 @@ export const postTokenOrganization = withRateLimit("token")( const user = await getUserBySub(context, payload.sub); if (!user) throw new InvalidGrantError("User not found"); - const { getUserOrgAccess, resolveOrganizationContext } = await import( - "../../models/rbac.ts" - ); + const { getUserOrgAccess, resolveOrganizationContext } = await import("../../models/rbac.ts"); const { organizationId, organizationSlug } = await resolveOrganizationContext( context, user.sub, @@ -410,10 +411,6 @@ export const postTokenOrganization = withRateLimit("token")( } satisfies SessionData; const s = await createSession(context, "user", sessionData); tokenResponse.refresh_token = s.refreshToken; - const ttlSeconds = await getSessionTtlSeconds(context, "user"); - const refreshTtlSeconds = await getRefreshTokenTtlSeconds(context, "user"); - issueSessionCookies(response, s.sessionId, ttlSeconds, false); - issueRefreshTokenCookie(response, s.refreshToken, refreshTtlSeconds, false); } sendJson(response, 200, tokenResponse); diff --git a/packages/api/src/http/routers/userRouter.ts b/packages/api/src/http/routers/userRouter.ts index ec263ce..76990a2 100644 --- a/packages/api/src/http/routers/userRouter.ts +++ b/packages/api/src/http/routers/userRouter.ts @@ -225,9 +225,7 @@ export function createUserRouter(context: Context) { ].includes(pathname); const isOAuthPost = method === "POST" && - ["/token", "/token/organization", "/userinfo", "/introspect", "/revoke"].includes( - pathname - ); + ["/token", "/token/organization", "/userinfo", "/introspect", "/revoke"].includes(pathname); const isScimRequest = pathname.startsWith("/scim/v2/"); const needsCsrf = !["GET", "HEAD", "OPTIONS"].includes(method) && diff --git a/packages/darkauth-client/README.md b/packages/darkauth-client/README.md index d4c04cd..329baaf 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 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. +Use `{ force: true }` after hosted first-party organization changes so the app receives tokens for the newly selected organization even if the current in-memory ID token has not expired. ### Organization Switching @@ -126,7 +126,7 @@ DarkAuth treats organization switching as choosing a new authorization context. #### `listOrganizations(): Promise` -Returns the current user's organizations for app-owned switcher UI. Use `status` to decide which memberships are selectable. +Returns the current user's organizations for app-owned switcher UI. When the SDK has a current app access token, the request is authorized with `Authorization: Bearer ` and does not depend on DarkAuth session cookies. Use `status` to decide which memberships are selectable. #### `getSessionInfo(): Promise<{ authenticated: boolean; sub?: string; email?: string | null; name?: string | null; organizationId?: string; organizationSlug?: string | null }>` @@ -134,7 +134,7 @@ Returns current first-party session and organization context for app chrome befo #### `switchOrganization(organizationId: string, options?: SwitchOrganizationOptions): Promise` -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. +Switches the selected organization. The default `token` mode exchanges the current app access token for fresh tokens scoped to the selected organization. `authorize` mode starts a new authorization-code flow. `hosted` mode redirects to DarkAuth's `/switch-org` page. #### App-owned switcher @@ -156,7 +156,7 @@ async function selectOrganization(organizationId: string) { } ``` -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`. +After the exchange, 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. diff --git a/packages/darkauth-client/src/index.ts b/packages/darkauth-client/src/index.ts index aedc6ab..c6bf30d 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?: "token" | "silent" | "authorize" | "hosted"; + mode?: "token" | "authorize" | "hosted"; returnTo?: string; }; @@ -763,7 +763,12 @@ export async function refreshSession( } export async function listOrganizations(): Promise { + const accessToken = + memorySession?.accessToken || + (tokenStorageMode() === "localStorage" ? getStoredAccessToken() : null); + const headers = accessToken ? { authorization: `Bearer ${accessToken}` } : undefined; const response = await fetch(rootEndpoint("/api/user/organizations"), { + headers, credentials: fetchCredentials(), }); if (!response.ok) throw await errorForResponse(response); @@ -808,7 +813,7 @@ export async function switchOrganization( const mode = options.mode || "token"; if (mode === "token") { const current = getStoredSession(); - const bearerToken = current?.accessToken || current?.idToken; + const bearerToken = current?.accessToken; if (!current || !bearerToken) { await initiateLogin({ organizationId, returnTo: options.returnTo }); return null; @@ -845,21 +850,6 @@ export async function switchOrganization( refreshToken, }); } - 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 null; diff --git a/packages/darkauth-client/tests/organizations.test.js b/packages/darkauth-client/tests/organizations.test.js index fd1f26e..b945055 100644 --- a/packages/darkauth-client/tests/organizations.test.js +++ b/packages/darkauth-client/tests/organizations.test.js @@ -39,9 +39,12 @@ function setupEnvironment() { test("listOrganizations fetches user organizations with roles", async () => { setupEnvironment(); + setConfig({ tokenStorage: "localStorage" }); + globalThis.localStorage.setItem("access_token", "current-access-token"); globalThis.fetch = async (url, init) => { assert.equal(url, "https://issuer.example/api/user/organizations"); assert.equal(init.credentials, "include"); + assert.equal(init.headers.authorization, "Bearer current-access-token"); return { ok: true, json: async () => ({ @@ -71,6 +74,23 @@ test("listOrganizations fetches user organizations with roles", async () => { ]); }); +test("listOrganizations falls back to session cookies before app tokens exist", async () => { + setupEnvironment(); + globalThis.fetch = async (url, init) => { + assert.equal(url, "https://issuer.example/api/user/organizations"); + assert.equal(init.credentials, "include"); + assert.equal(init.headers, undefined); + return { + ok: true, + json: async () => ({ organizations: [] }), + }; + }; + + const organizations = await listOrganizations(); + + assert.deepEqual(organizations, []); +}); + test("listOrganizations throws typed unauthenticated error on 401", async () => { setupEnvironment(); globalThis.fetch = async () => ({ diff --git a/specs/ORG_SWITCHING.md b/specs/ORG_SWITCHING.md index 9f063dd..753c00d 100644 --- a/specs/ORG_SWITCHING.md +++ b/specs/ORG_SWITCHING.md @@ -4,10 +4,17 @@ DarkAuth already supports organization-scoped login. The hosted authorize UI can ask a multi-org user which organization to use, `/authorize` accepts an `organization_id` hint, `/switch-org` lets a signed-in user change their DarkAuth session organization, and `/token` mints ID/access tokens with the selected `org_id`, `org_slug`, roles, and permissions. -The remaining gap is product and SDK shape. A relying-party app such as Atlas wants a Slack-like organization rail inside its own UI: list my organizations, click one, receive a fresh token for that organization, clear app tenant state, and continue without forcing a logout/login loop. DarkAuth should make that flow feel like a normal identity-provider feature, comparable to organization switching in Auth0, Clerk, Frontegg, WorkOS, or similar providers. +The key integration requirement is product and SDK shape. A relying-party app such as Atlas wants a Slack-like organization rail inside its own UI: list my organizations, click one, receive a fresh token for that organization, clear app tenant state, and continue without forcing a logout/login loop. DarkAuth should make that flow feel like a normal identity-provider feature, comparable to organization switching in Auth0, Clerk, Frontegg, WorkOS, or similar providers. This spec defines the DarkAuth API, SDK, and documentation work needed to support that pattern in an industry-standard way. +## Research Notes + +- OAuth 2.0 Token Exchange, RFC 8693, defines the exact authorization-server pattern of presenting a current `subject_token` and receiving a new token with a different authorization context. DarkAuth's app-owned organization switch endpoint is a product-specific variant of that pattern. +- OAuth 2.0 Security Best Current Practice, RFC 9700, allows CORS on browser-accessed token endpoints, but refresh tokens must be issued only after client risk assessment and must use replay detection such as rotation or sender constraint for public clients. +- Browser-based OAuth guidance treats direct browser token handling as higher risk than server-mediated auth, so DarkAuth must keep access tokens short-lived, avoid broadening scope on exchange, and avoid relying on cross-site identity-provider cookies for app-owned switchers. +- Auth0, Clerk, and WorkOS all model the active organization as token/session authorization context, not just UI state. Auth0 documents validating `org_id` in tokens and segmenting API data by it; Clerk includes active-organization claims in session tokens; WorkOS added organization switching by allowing refresh-token authentication to request an organization ID. + ## Current State DarkAuth has the important backend primitives: @@ -15,7 +22,7 @@ DarkAuth has the important backend primitives: - `GET /authorize` accepts optional `organization_id`. - `POST /authorize/finalize` binds the selected org to the authorization code and session. - `POST /token` issues org-scoped ID/access tokens for authorization-code and refresh-token grants. -- `GET /api/user/organizations` lists active organizations for the current user session. +- `GET /api/user/organizations` lists active organizations for the current user session or a current app access token. - `GET /api/user/session` returns current session organization context. - `POST /api/user/session/organization` validates membership and updates `sessions.data.organizationId`. - `/switch-org` is a hosted first-party UI that calls the session organization endpoint and validates `return_to`. @@ -55,7 +62,7 @@ DarkAuth should treat organization switching as selecting a new authorization co Recommended app-owned flow: -1. App asks DarkAuth for active organizations. +1. App asks DarkAuth for active organizations using its current app access token. 2. User selects an organization inside the app. 3. App calls `switchOrganization()`. 4. The SDK presents the current app-issued DarkAuth access token to `POST /api/token/organization`. @@ -64,7 +71,7 @@ Recommended app-owned flow: 7. DarkAuth returns fresh org-scoped ID/access tokens for the same client. 8. App stores the new session, clears tenant-local state, and loads data for the selected org. -This avoids repeat consent screens for already-authorized apps without relying on cross-origin mutation of the DarkAuth browser session. +This avoids repeat consent screens for already-authorized apps without relying on cross-origin mutation of the DarkAuth browser session or browser delivery of DarkAuth's first-party cookies. Hosted fallback flow: @@ -101,6 +108,7 @@ Keep: ```text GET /api/user/organizations +Authorization: Bearer ``` Response should be stable and SDK-friendly: @@ -123,9 +131,14 @@ Response should be stable and SDK-friendly: Rules: -- Return only organizations where the current user has membership. +- When an `Authorization` header is present, require a valid DarkAuth app access token. +- Reject ID tokens and client-credentials access tokens. +- Validate the token issuer, signature, expiry, and issuing client. +- When no bearer token is present, fall back to the first-party DarkAuth session cookie for hosted DarkAuth UI compatibility. +- Return only organizations where the current user has active membership. - SDK helpers should filter to active organizations by default. - Do not leak organizations where the user is not a member. +- Do not require a DarkAuth session cookie when a valid bearer access token is provided. ### Session Organization @@ -175,11 +188,13 @@ Request: Rules: -- Require a valid DarkAuth JWT issued to `client_id`. +- Require a valid DarkAuth access token issued to `client_id`. +- Reject ID tokens because they are authentication evidence for the client, not API authorization. - Reject client-credentials tokens because they are not user authorization. - Validate active membership in `organization_id`. - Mint new ID/access tokens for the same client with only the selected organization's claims. -- Issue a new refresh session for clients that support refresh tokens so future refreshes stay in the switched organization. +- Issue a new refresh token response for clients that support refresh tokens so future token refreshes stay in the switched organization. +- Do not set first-party DarkAuth session or refresh cookies from this endpoint. - Allow CORS only for registered public SPA origins. - Do not require first-party DarkAuth cookies or CSRF tokens; the bearer app token is the authority. @@ -204,7 +219,7 @@ export type InitiateLoginOptions = { }; export type SwitchOrganizationOptions = { - mode?: "silent" | "authorize" | "hosted"; + mode?: "token" | "authorize" | "hosted"; returnTo?: string; }; ``` @@ -230,9 +245,10 @@ export async function listOrganizations(): Promise Behavior: - Fetch from `GET /api/user/organizations`. -- Use `credentials: include`. -- Return active and inactive memberships as the API returns them, or add `listActiveOrganizations()` if the SDK should filter. -- Throw a typed auth/session error on 401. +- If a stored app access token is available, send it as `Authorization: Bearer `. +- If no app access token exists yet, use the first-party session-cookie fallback. +- Return active memberships. +- Throw a typed auth/token error on 401. ### `getSessionInfo()` @@ -275,12 +291,6 @@ Authorize behavior: - The app handles the callback with existing `handleCallback()`. - DarkAuth may auto-finalize the request without showing consent when the browser session was already issued for the same client and covers the requested scopes. -Silent behavior: - -- `mode: "silent"` may call `POST /api/user/session/organization` and force `refreshSession({ force: true })`. -- Use this only in trusted first-party contexts where DarkAuth's same-origin/CSRF requirements can be satisfied. -- Public cross-origin SPAs should use the default authorize behavior. - Hosted behavior: - Redirect to `/switch-org?organization_id=...&client_id=...&return_to=...`. @@ -344,10 +354,15 @@ For login: - Never trust an org selected in app UI without server-side validation. - Never mint tokens for an org where the user lacks active membership. +- Never use an ID token as authority for token exchange or organization listing. - 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. - Token organization switching must require a current app-issued bearer token for the same client. +- Token organization switching must not set first-party DarkAuth cookies as a side effect. +- Do not broaden scope or permissions during organization exchange. +- Keep exchanged access tokens short-lived. +- If refresh tokens are issued to public browser clients, use refresh-token rotation or another replay detection strategy. - 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. @@ -355,9 +370,9 @@ For login: ## Acceptance Criteria -- An app can list the signed-in user's organizations through `@darkauth/client`. +- An app can list the signed-in user's organizations through `@darkauth/client` using the current app access token, without DarkAuth session cookies. - 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` without a repeat authorize/consent screen when the existing session already covers the same client and scopes. +- 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 current access token is still valid and the user is an active member of the target org. - 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. @@ -371,6 +386,8 @@ For login: - [x] Confirm `/token` refresh grant returns the current session organization after `/api/user/session/organization`. - [x] Keep `POST /api/user/session/organization` same-origin and CSRF protected. - [x] Add bearer-token `POST /api/token/organization` for already-authorized app-owned org switching. +- [x] Ensure `POST /api/token/organization` requires an access token and does not set first-party DarkAuth cookies. +- [x] Allow `GET /api/user/organizations` to use a bearer app access token instead of requiring the DarkAuth session cookie. - [x] Ensure `GET /api/user/organizations` includes role summaries needed by app switcher UIs. - [x] Add or confirm `ORG_CONTEXT_REQUIRED` docs for refresh-token and authorize edge cases. - [x] Add OpenAPI docs for organization list, session info, and session organization endpoints.