From 750e5d2eeaa6be9517a688b486970ae866178032 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Tue, 2 Jun 2026 23:47:23 +0100 Subject: [PATCH] fix(api,sdk): switch organizations with app token --- .../controllers/user/oauthEndpoints.test.ts | 65 +++++- packages/api/src/controllers/user/token.ts | 197 +++++++++++++++++- packages/api/src/http/createServer.test.ts | 4 + packages/api/src/http/createServer.ts | 2 + packages/api/src/http/routers/userRouter.ts | 11 +- packages/darkauth-client/src/index.ts | 43 +++- .../tests/initiateLogin.test.js | 74 ++++++- specs/ORG_SWITCHING.md | 66 ++++-- 8 files changed, 425 insertions(+), 37 deletions(-) diff --git a/packages/api/src/controllers/user/oauthEndpoints.test.ts b/packages/api/src/controllers/user/oauthEndpoints.test.ts index a560170..ea902aa 100644 --- a/packages/api/src/controllers/user/oauthEndpoints.test.ts +++ b/packages/api/src/controllers/user/oauthEndpoints.test.ts @@ -8,7 +8,7 @@ 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 { generateEdDSAKeyPair, signJWT, storeKeyPair } from "../../services/jwks.ts"; +import { generateEdDSAKeyPair, signJWT, storeKeyPair, verifyJWT } from "../../services/jwks.ts"; import { createSession, getActiveRefreshTokenSession, @@ -18,7 +18,7 @@ import type { Context } from "../../types.ts"; import { sha256Base64Url } from "../../utils/crypto.ts"; import { postIntrospect } from "./introspect.ts"; import { postRevoke } from "./revoke.ts"; -import { postToken } from "./token.ts"; +import { postToken, postTokenOrganization } from "./token.ts"; import { handleUserinfo } from "./userinfo.ts"; import { getWellKnownOpenidConfiguration } from "./wellKnownOpenid.ts"; @@ -127,12 +127,12 @@ async function createUser(context: Context) { }); } -async function createUserOrganization(context: Context) { +async function createUserOrganization(context: Context, slug = "test-org") { const [organization] = await context.db .insert(organizations) .values({ - slug: "test-org", - name: "Test Org", + slug, + name: slug === "test-org" ? "Test Org" : "Second Org", createdByUserSub: "user-sub", }) .returning(); @@ -464,6 +464,61 @@ test("token allows hosted first-party cookie refresh for public SPA clients", as } }); +test("token organization switch mints target-org tokens from current app token", async () => { + const { context, cleanup } = await createContext(); + try { + await createUser(context); + await createUserOrganization(context, "first-org"); + const targetOrganization = await createUserOrganization(context, "second-org"); + 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: "POST", + url: "/token/organization", + authorization: `Bearer ${accessToken}`, + body: JSON.stringify({ + organization_id: targetOrganization.id, + client_id: "public-refresh-client", + }), + }); + const response = createResponse(); + + 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 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"); + assert.equal(typeof json.refresh_token, "string"); + 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))); + } finally { + await cleanup(); + } +}); + test("introspect returns active metadata for same-client access tokens", async () => { const { context, cleanup } = await createContext(); try { diff --git a/packages/api/src/controllers/user/token.ts b/packages/api/src/controllers/user/token.ts index 46f2d07..a61ea1c 100644 --- a/packages/api/src/controllers/user/token.ts +++ b/packages/api/src/controllers/user/token.ts @@ -2,10 +2,16 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { eq } from "drizzle-orm"; import { z } from "zod/v4"; import { users } from "../../db/schema.ts"; -import { InvalidGrantError, InvalidRequestError, UnauthorizedClientError } from "../../errors.ts"; +import { + ForbiddenError, + InvalidGrantError, + InvalidRequestError, + UnauthorizedClientError, + UnauthorizedError, +} from "../../errors.ts"; import { genericErrors } from "../../http/openapi-helpers.ts"; import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.ts"; -import { signJWT } from "../../services/jwks.ts"; +import { signJWT, verifyJWT } from "../../services/jwks.ts"; import { clearRefreshTokenCookie, createSession, @@ -41,6 +47,11 @@ import { } from "../../utils/http.ts"; import { verifyCodeChallenge } from "../../utils/pkce.ts"; +const TokenOrganizationRequestSchema = z.object({ + organization_id: z.string().uuid(), + client_id: z.string().min(1), +}); + async function resolveIssuer(context: Context): Promise { const issuerSetting = await getSetting(context, "issuer"); if (typeof issuerSetting === "string" && issuerSetting.length > 0) return issuerSetting; @@ -163,6 +174,34 @@ function resolveDelegatedPermissions(permissions: string[], grantedScopes: strin return permissions.filter((permission) => grantedScopeSet.has(permission)); } +function getBearerToken(request: IncomingMessage): string { + const auth = request.headers.authorization; + if (typeof auth !== "string") throw new UnauthorizedError("Bearer token required"); + const [scheme, token] = auth.split(" "); + if (!scheme || scheme.toLowerCase() !== "bearer" || !token) { + throw new UnauthorizedError("Bearer token required"); + } + return token; +} + +function tokenAudienceMatches(audience: unknown, clientId: string): boolean { + if (typeof audience === "string") return audience === clientId; + if (Array.isArray(audience)) return audience.some((item) => item === clientId); + return false; +} + +function assertTokenIssuedToClient(payload: import("jose").JWTPayload, clientId: string): void { + if (typeof payload.azp === "string" && payload.azp !== clientId) { + throw new ForbiddenError("Token was not issued to this client"); + } + if (!tokenAudienceMatches(payload.aud, clientId)) { + throw new ForbiddenError("Token was not issued to this client"); + } + if (payload.grant_type === "client_credentials") { + throw new ForbiddenError("User token required"); + } +} + export function resolveSessionClientId(sessionData: unknown): string | null { if (!sessionData || typeof sessionData !== "object") return null; const maybeClientId = (sessionData as { clientId?: unknown }).clientId; @@ -228,6 +267,160 @@ export const TokenRequestSchema = z.union([ }), ]); +export const postTokenOrganization = withRateLimit("token")( + withAudit({ + eventType: "TOKEN_ISSUED", + resourceType: "token", + extractResourceId: (body) => + body && typeof body === "object" && "client_id" in body + ? (body as { client_id?: string }).client_id + : undefined, + })( + async ( + context: Context, + request: IncomingMessage, + response: ServerResponse, + ..._params: unknown[] + ): Promise => { + const body = await getCachedBody(request); + let rawRequest: unknown; + try { + rawRequest = JSON.parse(body); + } catch { + throw new InvalidRequestError("Invalid JSON body"); + } + const parsedRequest = TokenOrganizationRequestSchema.safeParse(rawRequest); + if (!parsedRequest.success) { + throw new InvalidRequestError(parsedRequest.error.issues[0]?.message || "Invalid request"); + } + + const token = getBearerToken(request); + const clientId = parsedRequest.data.client_id; + const requestedOrganizationId = parsedRequest.data.organization_id; + const payload = await verifyJWT(context, token, clientId); + if (typeof payload.sub !== "string" || payload.sub.length === 0) { + throw new UnauthorizedError("User token required"); + } + assertTokenIssuedToClient(payload, clientId); + + const { getClient } = await import("../../models/clients.ts"); + const client = await getClient(context, clientId); + if (!client) throw new UnauthorizedClientError("Unknown client"); + if (!client.grantTypes.includes("authorization_code")) { + throw new UnauthorizedClientError("authorization_code grant not allowed for this client"); + } + + const { getUserBySub } = await import("../../models/users.ts"); + const user = await getUserBySub(context, payload.sub); + if (!user) throw new InvalidGrantError("User not found"); + + const { getUserOrgAccess, resolveOrganizationContext } = await import( + "../../models/rbac.ts" + ); + const { organizationId, organizationSlug } = await resolveOrganizationContext( + context, + user.sub, + requestedOrganizationId + ); + const { roleKeys, permissions: organizationPermissions } = await getUserOrgAccess( + context, + user.sub, + organizationId + ); + const directPermissionRows = await context.db.query.userPermissions.findMany({ + where: (table, { eq }) => eq(table.userSub, user.sub), + }); + const uniquePermissions = Array.from( + new Set([ + ...organizationPermissions, + ...directPermissionRows.map((row) => row.permissionKey), + ]) + ).sort(); + + const allowedScopes = resolveClientScopeKeys(client.scopes); + const grantedScope = + typeof payload.scope === "string" && payload.scope.length > 0 + ? resolveGrantedScopes(allowedScopes, payload.scope).join(" ") + : resolveGrantedScopes(allowedScopes).join(" "); + const grantedScopes = parseScopeString(grantedScope); + const delegatedPermissions = resolveDelegatedPermissions(uniquePermissions, grantedScopes); + const now = Math.floor(Date.now() / 1000); + const idTokenTtl = + client.idTokenLifetimeSeconds && client.idTokenLifetimeSeconds > 0 + ? client.idTokenLifetimeSeconds + : 300; + const issuer = await resolveIssuer(context); + const idTokenClaims = buildUserIdTokenClaims({ + issuer, + subject: user.sub, + audience: clientId, + expiresAtSeconds: now + idTokenTtl, + issuedAtSeconds: now, + email: user.email, + name: user.name, + orgId: organizationId, + orgSlug: organizationSlug, + roles: roleKeys, + permissions: uniquePermissions, + amr: ["pwd"], + }); + const idToken = await signJWT( + context, + idTokenClaims as import("jose").JWTPayload, + `${idTokenTtl}s` + ); + const accessTokenTtl = resolveAccessTokenLifetimeSeconds(client); + const accessTokenClaims = buildUserAccessTokenClaims({ + issuer, + subject: user.sub, + audience: clientId, + authorizedParty: clientId, + expiresAtSeconds: now + accessTokenTtl, + issuedAtSeconds: now, + scope: grantedScope, + grantType: "authorization_code", + orgId: organizationId, + orgSlug: organizationSlug, + roles: roleKeys, + permissions: delegatedPermissions, + }); + const accessToken = await signJWT( + context, + accessTokenClaims as import("jose").JWTPayload, + `${accessTokenTtl}s` + ); + const tokenResponse: TokenResponse = { + access_token: accessToken, + id_token: idToken, + token_type: "Bearer", + expires_in: accessTokenTtl, + scope: grantedScope, + }; + + if (shouldIssueRefreshTokenForClient(client.grantTypes)) { + const sessionData = { + sub: user.sub, + email: user.email || undefined, + name: user.name || undefined, + organizationId, + organizationSlug: organizationSlug || undefined, + clientId, + scope: grantedScope, + keyState: "locked", + } 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); + } + ) +); + export const postToken = withRateLimit("token")( withAudit({ eventType: "TOKEN_ISSUED", diff --git a/packages/api/src/http/createServer.test.ts b/packages/api/src/http/createServer.test.ts index c0cd89b..2750699 100644 --- a/packages/api/src/http/createServer.test.ts +++ b/packages/api/src/http/createServer.test.ts @@ -21,6 +21,10 @@ test("user CORS allows SDK user endpoints for registered public SPA origins", () isUserCorsOriginAllowed("/api/user/session", "https://atlas.wylde.net", corsPolicy), true ); + assert.equal( + isUserCorsOriginAllowed("/api/token/organization", "https://atlas.wylde.net", corsPolicy), + true + ); }); test("user CORS rejects SDK user endpoints for unregistered origins", () => { diff --git a/packages/api/src/http/createServer.ts b/packages/api/src/http/createServer.ts index e31cac9..fb10a4f 100644 --- a/packages/api/src/http/createServer.ts +++ b/packages/api/src/http/createServer.ts @@ -90,6 +90,8 @@ function isPublicSpaCorsPath(pathname: string): boolean { return ( pathname === "/token" || pathname === "/api/token" || + pathname === "/token/organization" || + pathname === "/api/token/organization" || pathname === "/userinfo" || pathname === "/api/userinfo" || pathname === "/revoke" || diff --git a/packages/api/src/http/routers/userRouter.ts b/packages/api/src/http/routers/userRouter.ts index 46323a0..ec263ce 100644 --- a/packages/api/src/http/routers/userRouter.ts +++ b/packages/api/src/http/routers/userRouter.ts @@ -91,7 +91,7 @@ import { import { postRevoke } from "../../controllers/user/revoke.ts"; import { getScopeDescriptions } from "../../controllers/user/scopeDescriptions.ts"; import { getSession, postSessionOrganization } from "../../controllers/user/session.ts"; -import { postToken } from "../../controllers/user/token.ts"; +import { postToken, postTokenOrganization } from "../../controllers/user/token.ts"; import { getDeviceApprovalRequests, getTrustedDevices, @@ -224,7 +224,10 @@ export function createUserRouter(context: Context) { "/webauthn/login/finish", ].includes(pathname); const isOAuthPost = - method === "POST" && ["/token", "/userinfo", "/introspect", "/revoke"].includes(pathname); + method === "POST" && + ["/token", "/token/organization", "/userinfo", "/introspect", "/revoke"].includes( + pathname + ); const isScimRequest = pathname.startsWith("/scim/v2/"); const needsCsrf = !["GET", "HEAD", "OPTIONS"].includes(method) && @@ -547,6 +550,10 @@ export function createUserRouter(context: Context) { return await postToken(context, request, response); } + if (method === "POST" && pathname === "/token/organization") { + return await postTokenOrganization(context, request, response); + } + if ((method === "GET" || method === "POST") && pathname === "/userinfo") { return await handleUserinfo(context, request, response); } diff --git a/packages/darkauth-client/src/index.ts b/packages/darkauth-client/src/index.ts index d7f54a3..aedc6ab 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?: "silent" | "authorize" | "hosted"; + mode?: "token" | "silent" | "authorize" | "hosted"; returnTo?: string; }; @@ -805,7 +805,46 @@ export async function switchOrganization( organizationId: string, options: SwitchOrganizationOptions = {} ): Promise { - const mode = options.mode || "authorize"; + const mode = options.mode || "token"; + if (mode === "token") { + const current = getStoredSession(); + const bearerToken = current?.accessToken || current?.idToken; + if (!current || !bearerToken) { + await initiateLogin({ organizationId, returnTo: options.returnTo }); + return null; + } + const response = await fetch(rootEndpoint("/api/token/organization"), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify({ + organization_id: organizationId, + client_id: cfg.clientId, + }), + credentials: fetchCredentials(), + }); + if (!response.ok) throw await errorForResponse(response); + const tokenResponse = await response.json(); + const idToken = tokenResponse.id_token as string; + const accessToken = + typeof tokenResponse.access_token === "string" + ? (tokenResponse.access_token as string) + : undefined; + const refreshToken = + refreshMode() === "token" ? (tokenResponse.refresh_token as string | undefined) : undefined; + return storeSession({ + idToken, + accessToken, + drk: current.drk || EMPTY_DRK, + clientAppKey: current.clientAppKey, + rootKey: current.rootKey, + deliveredKeyKind: current.deliveredKeyKind, + keyDeliveryVersion: current.keyDeliveryVersion, + refreshToken, + }); + } if (mode === "silent") { const response = await fetch(rootEndpoint("/api/user/session/organization"), { method: "POST", diff --git a/packages/darkauth-client/tests/initiateLogin.test.js b/packages/darkauth-client/tests/initiateLogin.test.js index 5777d91..b65cb23 100644 --- a/packages/darkauth-client/tests/initiateLogin.test.js +++ b/packages/darkauth-client/tests/initiateLogin.test.js @@ -53,6 +53,14 @@ function createLocation() { }; } +function base64UrlEncodeJson(value) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function createJwt(payload) { + return `${base64UrlEncodeJson({ alg: "none", typ: "JWT" })}.${base64UrlEncodeJson(payload)}.sig`; +} + test("initiateLogin adds ZK parameters when zk is true", async () => { setupEnvironment(); const { location, getAssignedUrl } = createLocation(); @@ -95,24 +103,78 @@ 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 exchanges current app token by default", async () => { setupEnvironment(); const { location, getAssignedUrl } = createLocation(); globalThis.location = location; + const currentAccessToken = createJwt({ + sub: "user-sub", + aud: "client-id", + azp: "client-id", + exp: Math.floor(Date.now() / 1000) + 3600, + scope: "openid profile", + token_use: "access", + }); + const currentIdToken = createJwt({ + sub: "user-sub", + aud: "client-id", + exp: Math.floor(Date.now() / 1000) + 3600, + }); + const switchedIdToken = createJwt({ + sub: "user-sub", + aud: "client-id", + exp: Math.floor(Date.now() / 1000) + 3600, + org_id: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff", + }); + const switchedAccessToken = createJwt({ + sub: "user-sub", + aud: "client-id", + azp: "client-id", + exp: Math.floor(Date.now() / 1000) + 3600, + org_id: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff", + scope: "openid profile", + token_use: "access", + }); + globalThis.localStorage.setItem("id_token", currentIdToken); + globalThis.localStorage.setItem("access_token", currentAccessToken); + globalThis.fetch = async (url, options) => { + assert.equal(url, "https://issuer.example/api/token/organization"); + assert.equal(options.method, "POST"); + assert.equal(options.headers.authorization, `Bearer ${currentAccessToken}`); + assert.deepEqual(JSON.parse(options.body), { + organization_id: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff", + client_id: "client-id", + }); + return { + ok: true, + json: async () => ({ + token_type: "Bearer", + expires_in: 600, + id_token: switchedIdToken, + access_token: switchedAccessToken, + refresh_token: "new-refresh-token", + }), + }; + }; setConfig({ issuer: "https://issuer.example", clientId: "client-id", redirectUri: "https://app.example/callback", zk: false, discovery: false, + tokenStorage: "localStorage", + refreshMode: "token", }); - await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff"); + const session = await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff"); - const url = new URL(getAssignedUrl()); - assert.equal(url.pathname, "/authorize"); - assert.equal(url.searchParams.get("organization_id"), "8f9778b7-0f1d-46cb-ae32-74f03300f6ff"); - assert.equal(url.searchParams.get("client_id"), "client-id"); + assert.equal(getAssignedUrl(), ""); + assert.equal(session.idToken, switchedIdToken); + assert.equal(session.accessToken, switchedAccessToken); + assert.equal(session.refreshToken, "new-refresh-token"); + assert.equal(globalThis.localStorage.getItem("id_token"), switchedIdToken); + assert.equal(globalThis.localStorage.getItem("access_token"), switchedAccessToken); + assert.equal(globalThis.localStorage.getItem("refresh_token"), "new-refresh-token"); }); test("switchOrganization can start authorize flow", async () => { diff --git a/specs/ORG_SWITCHING.md b/specs/ORG_SWITCHING.md index 47a65ff..9f063dd 100644 --- a/specs/ORG_SWITCHING.md +++ b/specs/ORG_SWITCHING.md @@ -58,14 +58,13 @@ Recommended app-owned flow: 1. App asks DarkAuth for active organizations. 2. User selects an organization inside the app. 3. App calls `switchOrganization()`. -4. The SDK starts a new authorization request with `organization_id=`. -5. DarkAuth validates the user's active membership. -6. DarkAuth skips repeat consent when the current browser session was already issued for the same client and covers the requested scopes. -7. DarkAuth returns an authorization code to the registered app callback. -8. App handles the callback and receives a new org-scoped session/token. -9. App clears tenant-local state and loads data for the selected org. +4. The SDK presents the current app-issued DarkAuth access token to `POST /api/token/organization`. +5. DarkAuth verifies the token was issued to the same client. +6. DarkAuth validates the user's active membership in the requested organization. +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 stays inside OAuth redirect, PKCE, state, redirect URI, and token issuance rules while avoiding the repeated approval screen for already-authorized sessions. +This avoids repeat consent screens for already-authorized apps without relying on cross-origin mutation of the DarkAuth browser session. Hosted fallback flow: @@ -155,17 +154,34 @@ Rules: - If the session org is missing and the user has multiple active orgs, return `ORG_CONTEXT_REQUIRED`. - Do not accept arbitrary `organization_id` on refresh-token grant unless DarkAuth intentionally implements an extension with clear security rules. -### Optional Future Extension +### Token Organization Switch -If DarkAuth later wants a non-redirect token switch, use a formal extension instead of an ad hoc parameter: +Add: -- OAuth 2.0 Token Exchange style endpoint or grant. -- Request includes subject token/session context and requested organization. -- Server validates active membership and client authorization. -- Response returns fresh org-scoped tokens. -- Public browser clients still need PKCE/session protections and should prefer redirect-based authorization unless the extension is carefully designed. +```text +POST /api/token/organization +Authorization: Bearer +Content-Type: application/json +``` -This is a future optimization, not required for Atlas. +Request: + +```json +{ + "organization_id": "uuid", + "client_id": "atlas" +} +``` + +Rules: + +- Require a valid DarkAuth JWT issued to `client_id`. +- 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. +- 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. ## SDK Contract @@ -246,9 +262,15 @@ export async function switchOrganization( ): Promise ``` -Default behavior should be `mode: "authorize"`: +Default behavior should be `mode: "token"`: + +- Call `POST /api/token/organization` using the current stored app access token. +- Store and return the fresh org-scoped session. +- Fall back to `initiateLogin({ organizationId, returnTo })` only when there is no current app token. + +Authorize behavior: -- Call `initiateLogin({ organizationId, returnTo })`. +- `mode: "authorize"` calls `initiateLogin({ organizationId, returnTo })`. - This produces a normal authorization-code flow and fresh org-scoped token. - 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. @@ -305,7 +327,7 @@ 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 handles callback, validates new `org_id`, clears tenant-local state, and reloads. +- App validates new `org_id`, clears tenant-local state, and reloads. For hosted switching: @@ -325,6 +347,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. +- Token organization switching must require a current app-issued bearer token for the same client. - 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. @@ -347,6 +370,7 @@ For login: - [x] Confirm `GET /authorize?organization_id=...` works for public clients with PKCE and current tests cover the Atlas client shape. - [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 `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. @@ -359,7 +383,7 @@ For login: - [x] Add `listOrganizations()`. - [x] Add `getSessionInfo()`. - [x] Add `switchOrganization(organizationId, options?)`. -- [x] Default `switchOrganization(organizationId)` to authorize-code org switching. +- [x] Default `switchOrganization(organizationId)` to bearer-token org switching. - [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. @@ -376,7 +400,9 @@ For login: - [x] SDK test for `initiateLogin({ organizationId })` authorization URL. - [x] SDK test for `listOrganizations()`. -- [x] SDK test for `switchOrganization()` default authorize mode. +- [x] SDK test for `switchOrganization()` explicit authorize mode. +- [x] SDK test for `switchOrganization()` default token mode. +- [x] API test for bearer-token organization switch minting target-org tokens. - [x] SDK test for hosted switch URL generation. - [x] SDK test for `refreshSession({ force: true })`. - [x] API test that hosted session switch plus refresh mints token for new org.