From 2b16cb42a7ecf14691fc265a29f3c11390b0a349 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Tue, 2 Jun 2026 23:13:45 +0100 Subject: [PATCH] fix(api,user-ui,sdk): skip repeat org switch consent --- .../api/src/controllers/user/authorize.ts | 24 ++++++++- packages/api/src/http/createServer.test.ts | 8 --- packages/api/src/http/createServer.ts | 3 +- packages/darkauth-client/src/index.ts | 2 +- .../tests/initiateLogin.test.js | 43 +++------------ packages/user-ui/src/App.tsx | 4 ++ packages/user-ui/src/components/Authorize.tsx | 16 ++++++ specs/ORG_SWITCHING.md | 52 +++++++------------ 8 files changed, 69 insertions(+), 83 deletions(-) diff --git a/packages/api/src/controllers/user/authorize.ts b/packages/api/src/controllers/user/authorize.ts index 26246f5e..50726051 100644 --- a/packages/api/src/controllers/user/authorize.ts +++ b/packages/api/src/controllers/user/authorize.ts @@ -17,6 +17,22 @@ import { parseQueryParams } from "../../utils/http.ts"; import { validateCodeChallenge } from "../../utils/pkce.ts"; 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)); +} + +function sessionCoversAuthorization( + sessionData: Record | null | undefined, + clientId: string, + grantedScopes: string[] +): boolean { + if (!sessionData || sessionData.clientId !== clientId) return false; + const sessionScopes = parseScopeSet(sessionData.scope); + if (sessionScopes.size === 0) return false; + return grantedScopes.every((scope) => sessionScopes.has(scope)); +} + export const AuthorizationRequestSchema = z.object({ client_id: z.string().min(1, { message: "client_id is required" }), redirect_uri: z.string().min(1, { message: "redirect_uri is required" }), @@ -105,11 +121,14 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize( const sessionId = getSessionId(request); let userSub: string | undefined; + let sessionData: Record | null = null; if (sessionId) { - const sessionData = await getSession(context, sessionId); - userSub = sessionData?.sub; + sessionData = (await getSession(context, sessionId)) as Record | null; + userSub = typeof sessionData?.sub === "string" ? sessionData.sub : undefined; } + const autoFinalize = + !zkPubKid && sessionCoversAuthorization(sessionData, authRequest.client_id, grantedScopes); await createPendingAuth(context, { requestId, @@ -154,6 +173,7 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize( if (authRequest.redirect_uri) qs.set("redirect_uri", authRequest.redirect_uri); if (authRequest.state) qs.set("state", authRequest.state); if (authRequest.organization_id) qs.set("organization_id", authRequest.organization_id); + if (autoFinalize) qs.set("auto_finalize", "1"); const redirectTo = `/${qs.toString() ? `?${qs.toString()}` : ""}`; response.statusCode = 302; response.setHeader("Location", redirectTo); diff --git a/packages/api/src/http/createServer.test.ts b/packages/api/src/http/createServer.test.ts index 7750851a..c0cd89bd 100644 --- a/packages/api/src/http/createServer.test.ts +++ b/packages/api/src/http/createServer.test.ts @@ -21,14 +21,6 @@ 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/user/session/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 4687ebf1..e31cac9a 100644 --- a/packages/api/src/http/createServer.ts +++ b/packages/api/src/http/createServer.ts @@ -95,8 +95,7 @@ function isPublicSpaCorsPath(pathname: string): boolean { pathname === "/revoke" || pathname === "/api/revoke" || pathname === "/api/user/organizations" || - pathname === "/api/user/session" || - pathname === "/api/user/session/organization" + pathname === "/api/user/session" ); } diff --git a/packages/darkauth-client/src/index.ts b/packages/darkauth-client/src/index.ts index 256a3bef..d7f54a3c 100644 --- a/packages/darkauth-client/src/index.ts +++ b/packages/darkauth-client/src/index.ts @@ -805,7 +805,7 @@ export async function switchOrganization( organizationId: string, options: SwitchOrganizationOptions = {} ): Promise { - const mode = options.mode || "silent"; + const mode = options.mode || "authorize"; 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 64e1c63b..5777d919 100644 --- a/packages/darkauth-client/tests/initiateLogin.test.js +++ b/packages/darkauth-client/tests/initiateLogin.test.js @@ -53,15 +53,6 @@ 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(); @@ -104,33 +95,10 @@ test("initiateLogin includes organization_id when organizationId is supplied", a assert.equal(url.searchParams.get("organization_id"), "8f9778b7-0f1d-46cb-ae32-74f03300f6ff"); }); -test("switchOrganization updates the session organization and refreshes by default", async () => { +test("switchOrganization starts authorize flow 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", @@ -139,11 +107,12 @@ test("switchOrganization updates the session organization and refreshes by defau discovery: false, }); - const session = await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff"); + await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff"); - assert.equal(session.idToken, idToken); - assert.equal(getAssignedUrl(), ""); - assert.equal(calls.length, 2); + 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"); }); test("switchOrganization can start authorize flow", async () => { diff --git a/packages/user-ui/src/App.tsx b/packages/user-ui/src/App.tsx index a6f22336..9c5480ce 100644 --- a/packages/user-ui/src/App.tsx +++ b/packages/user-ui/src/App.tsx @@ -60,6 +60,7 @@ interface AuthRequest { state?: string; zkPub?: string; organizationId?: string; + autoFinalize?: boolean; } function decodeBase64Url(value: string): string { @@ -166,6 +167,7 @@ function AppContent() { const state = params.get("state") || undefined; const zkPub = params.get("zk_pub") || undefined; const organizationId = params.get("organization_id") || undefined; + const autoFinalize = params.get("auto_finalize") === "1"; setAuthRequest((current) => { if ( current && @@ -182,6 +184,7 @@ function AppContent() { current.state === state && current.zkPub === zkPub && current.organizationId === organizationId && + current.autoFinalize === autoFinalize && current.scopes.join(" ") === scopes.join(" ") ) { return current; @@ -200,6 +203,7 @@ function AppContent() { state, zkPub, organizationId, + autoFinalize, }; }); setAuthRequestSearch(search); diff --git a/packages/user-ui/src/components/Authorize.tsx b/packages/user-ui/src/components/Authorize.tsx index 1b5fa33f..85adf371 100644 --- a/packages/user-ui/src/components/Authorize.tsx +++ b/packages/user-ui/src/components/Authorize.tsx @@ -71,6 +71,7 @@ interface AuthorizeProps { state?: string; zkPub?: string; organizationId?: string; + autoFinalize?: boolean; unlockPolicy?: UnlockPolicy; }; sessionData: { @@ -110,6 +111,7 @@ export default function Authorize({ const [deviceApprovalLoading, setDeviceApprovalLoading] = useState(false); const [deviceApprovalStatus, setDeviceApprovalStatus] = useState(null); const deviceApprovalPollRef = useRef(null); + const autoFinalizeStartedRef = useRef(false); const [selectedOrganizationId, setSelectedOrganizationId] = useState( authRequest.organizationId || sessionData.organizationId || "" ); @@ -640,6 +642,20 @@ export default function Authorize({ } }; + useEffect(() => { + if ( + !authRequest.autoFinalize || + authRequest.hasZk || + organizationsLoading || + loading || + autoFinalizeStartedRef.current + ) { + return; + } + autoFinalizeStartedRef.current = true; + handleAuthorize(true); + }); + const generateNewKeys = async () => { logger.debug({ sub: sessionData.sub }, "[Authorize] generateNewKeys start"); if (!isUnlockMethodAllowed(unlockPolicy, "new_key")) { diff --git a/specs/ORG_SWITCHING.md b/specs/ORG_SWITCHING.md index fcd228ed..47a65ff9 100644 --- a/specs/ORG_SWITCHING.md +++ b/specs/ORG_SWITCHING.md @@ -53,26 +53,19 @@ The client does not currently expose typed organization helpers, and `initiateLo 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 for first-party browser apps with an existing DarkAuth session: +Recommended app-owned flow: 1. App asks DarkAuth for active organizations. 2. User selects an organization inside the app. 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. +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. -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. +This stays inside OAuth redirect, PKCE, state, redirect URI, and token issuance rules while avoiding the repeated approval screen for already-authorized sessions. Hosted fallback flow: @@ -253,19 +246,18 @@ export async function switchOrganization( ): Promise ``` -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: +Default behavior should be `mode: "authorize"`: - 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. +- 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: @@ -313,11 +305,6 @@ 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: @@ -347,7 +334,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` without a repeat authorize/consent screen when the DarkAuth session can be used silently. +- 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. - 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. @@ -372,7 +359,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] Default `switchOrganization(organizationId)` to authorize-code 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. @@ -389,8 +376,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 `switchOrganization()` default authorize mode. - [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.