diff --git a/packages/admin-ui/src/pages/ClientEdit.tsx b/packages/admin-ui/src/pages/ClientEdit.tsx index 1d82a00..a64b763 100644 --- a/packages/admin-ui/src/pages/ClientEdit.tsx +++ b/packages/admin-ui/src/pages/ClientEdit.tsx @@ -140,6 +140,7 @@ export default function ClientEdit({ mode = "edit" }: ClientEditProps) { zkRequired: false, keyDeliveryVersion: "v2" as Client["keyDeliveryVersion"], clientKeyScope: "organization" as Client["clientKeyScope"], + requireOrganizationSelection: true, showOnUserDashboard: false, dashboardAutoLogin: false, dashboardPosition: "0", @@ -224,6 +225,7 @@ export default function ClientEdit({ mode = "edit" }: ClientEditProps) { zkRequired: c.zkRequired, keyDeliveryVersion: c.keyDeliveryVersion || "v2", clientKeyScope: c.clientKeyScope || "organization", + requireOrganizationSelection: c.requireOrganizationSelection ?? true, redirectUris: joinList(c.redirectUris), postLogoutRedirectUris: joinList(c.postLogoutRedirectUris), grantTypes: joinList(c.grantTypes), @@ -266,6 +268,7 @@ export default function ClientEdit({ mode = "edit" }: ClientEditProps) { keyDeliveryVersion: form.keyDeliveryVersion, deliveredKeyKind: deliveredKeyKindFor(form.keyDeliveryVersion), clientKeyScope: form.clientKeyScope, + requireOrganizationSelection: form.requireOrganizationSelection, showOnUserDashboard: form.showOnUserDashboard, dashboardAutoLogin: form.dashboardAutoLogin, dashboardPosition: Number(form.dashboardPosition || "0"), @@ -993,6 +996,37 @@ export default function ClientEdit({ mode = "edit" }: ClientEditProps) { stay stable across organizations. + + + } + > + + + Disable this for generic OIDC clients that do not understand organization + context. + + diff --git a/packages/admin-ui/src/services/api.ts b/packages/admin-ui/src/services/api.ts index 6311977..736e544 100644 --- a/packages/admin-ui/src/services/api.ts +++ b/packages/admin-ui/src/services/api.ts @@ -229,6 +229,7 @@ export interface Client { keyDeliveryVersion: "v1-drk" | "v2"; deliveredKeyKind: "root_key" | "client_app_key"; clientKeyScope: "account" | "organization"; + requireOrganizationSelection: boolean; allowedJweAlgs: string[]; allowedJweEncs: string[]; redirectUris: string[]; @@ -269,6 +270,7 @@ export interface CreateClientRequest { keyDeliveryVersion?: "v1-drk" | "v2"; deliveredKeyKind?: "root_key" | "client_app_key"; clientKeyScope?: "account" | "organization"; + requireOrganizationSelection?: boolean; allowedJweAlgs?: string[]; allowedJweEncs?: string[]; redirectUris: string[]; diff --git a/packages/api/drizzle/0039_client_organization_selection.sql b/packages/api/drizzle/0039_client_organization_selection.sql new file mode 100644 index 0000000..72444b5 --- /dev/null +++ b/packages/api/drizzle/0039_client_organization_selection.sql @@ -0,0 +1,5 @@ +ALTER TABLE "clients" ADD COLUMN "require_organization_selection" boolean DEFAULT true NOT NULL; +--> statement-breakpoint +ALTER TABLE "pending_auth" ADD COLUMN "require_organization_selection" boolean DEFAULT true NOT NULL; +--> statement-breakpoint +ALTER TABLE "auth_codes" ADD COLUMN "require_organization_selection" boolean DEFAULT true NOT NULL; diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index 02e6454..63f92bb 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1780693200000, "tag": "0038_federation_email_linking_migration", "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1781125200000, + "tag": "0039_client_organization_selection", + "breakpoints": true } ] } diff --git a/packages/api/src/controllers/admin/clientCreate.ts b/packages/api/src/controllers/admin/clientCreate.ts index e9f1f40..57395db 100644 --- a/packages/api/src/controllers/admin/clientCreate.ts +++ b/packages/api/src/controllers/admin/clientCreate.ts @@ -98,6 +98,7 @@ export const CreateClientSchema = z.object({ keyDeliveryVersion: z.enum(["v1-drk", "v2"]).optional().default("v2"), deliveredKeyKind: z.enum(["root_key", "client_app_key"]).optional(), clientKeyScope: z.enum(["account", "organization"]).optional().default("organization"), + requireOrganizationSelection: z.boolean().optional().default(true), allowedJweAlgs: z.array(z.string()).optional().default([]), allowedJweEncs: z.array(z.string()).optional().default([]), redirectUris: z.array(z.string().url()).optional().default([]), @@ -135,6 +136,7 @@ export const ClientResponseSchema = z.object({ keyDeliveryVersion: z.string(), deliveredKeyKind: z.string(), clientKeyScope: z.string(), + requireOrganizationSelection: z.boolean(), allowedJweAlgs: z.array(z.string()), allowedJweEncs: z.array(z.string()), redirectUris: z.array(z.string()), diff --git a/packages/api/src/controllers/admin/clientUpdate.ts b/packages/api/src/controllers/admin/clientUpdate.ts index 98c7298..6fe57fc 100644 --- a/packages/api/src/controllers/admin/clientUpdate.ts +++ b/packages/api/src/controllers/admin/clientUpdate.ts @@ -39,6 +39,7 @@ async function updateClientHandler( keyDeliveryVersion: z.enum(["v1-drk", "v2"]).optional(), deliveredKeyKind: z.enum(["root_key", "client_app_key"]).optional(), clientKeyScope: z.enum(["account", "organization"]).optional(), + requireOrganizationSelection: z.boolean().optional(), showOnUserDashboard: z.boolean().optional(), dashboardAutoLogin: z.boolean().optional(), dashboardPosition: z.number().int().min(0).optional(), @@ -130,6 +131,7 @@ const Req = z.object({ keyDeliveryVersion: z.enum(["v1-drk", "v2"]).optional(), deliveredKeyKind: z.enum(["root_key", "client_app_key"]).optional(), clientKeyScope: z.enum(["account", "organization"]).optional(), + requireOrganizationSelection: z.boolean().optional(), showOnUserDashboard: z.boolean().optional(), dashboardAutoLogin: z.boolean().optional(), dashboardPosition: z.number().int().min(0).optional(), diff --git a/packages/api/src/controllers/admin/clients.ts b/packages/api/src/controllers/admin/clients.ts index 2c0b4b9..e6a1d15 100644 --- a/packages/api/src/controllers/admin/clients.ts +++ b/packages/api/src/controllers/admin/clients.ts @@ -32,6 +32,7 @@ const ClientResponseSchema = z.object({ keyDeliveryVersion: z.string(), deliveredKeyKind: z.string(), clientKeyScope: z.string(), + requireOrganizationSelection: z.boolean(), allowedJweAlgs: z.array(z.string()), allowedJweEncs: z.array(z.string()), redirectUris: z.array(z.string()), diff --git a/packages/api/src/controllers/user/authorize.ts b/packages/api/src/controllers/user/authorize.ts index fccf41f..98fa5a1 100644 --- a/packages/api/src/controllers/user/authorize.ts +++ b/packages/api/src/controllers/user/authorize.ts @@ -148,6 +148,7 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize( keyDeliveryVersion: zkPubKid ? client.keyDeliveryVersion : undefined, deliveredKeyKind: zkPubKid ? client.deliveredKeyKind : undefined, clientKeyScope: zkPubKid ? client.clientKeyScope : undefined, + requireOrganizationSelection: client.requireOrganizationSelection, userSub, organizationId: authRequest.organization_id, origin: `http://${request.headers.host}`, @@ -173,6 +174,7 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize( if (zkPubKid) qs.set("key_delivery_version", client.keyDeliveryVersion); if (zkPubKid) qs.set("delivered_key_kind", client.deliveredKeyKind); if (zkPubKid) qs.set("client_key_scope", client.clientKeyScope); + qs.set("require_organization_selection", client.requireOrganizationSelection ? "1" : "0"); if (authRequest.zk_pub && canonicalZkPub) qs.set("zk_pub", canonicalZkPub); if (authRequest.client_id) qs.set("client_id", authRequest.client_id); if (authRequest.redirect_uri) qs.set("redirect_uri", authRequest.redirect_uri); diff --git a/packages/api/src/controllers/user/authorizeFinalize.ts b/packages/api/src/controllers/user/authorizeFinalize.ts index 7bfcbe7..1196757 100644 --- a/packages/api/src/controllers/user/authorizeFinalize.ts +++ b/packages/api/src/controllers/user/authorizeFinalize.ts @@ -124,15 +124,17 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( const sessionOrganizationId = typeof sessionData.organizationId === "string" ? sessionData.organizationId : undefined; - const resolvedOrganization = await resolveAuthorizationOrganizationContext( - context, - sessionData.sub, - { - explicitOrganizationId: parsed.organization_id, - pendingOrganizationId: pendingRequest.organizationId, - sessionOrganizationId, - } - ); + const shouldResolveOrganization = + pendingRequest.requireOrganizationSelection || + !!pendingRequest.organizationId || + !!parsed.organization_id; + const resolvedOrganization = shouldResolveOrganization + ? await resolveAuthorizationOrganizationContext(context, sessionData.sub, { + explicitOrganizationId: parsed.organization_id, + pendingOrganizationId: pendingRequest.organizationId, + sessionOrganizationId, + }) + : null; const consumedPendingRequest = await consumePendingAuth(context, requestId, sessionData.sub); if (!consumedPendingRequest) { @@ -143,7 +145,7 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( code, clientId: consumedPendingRequest.clientId, userSub: sessionData.sub, - organizationId: resolvedOrganization.organizationId, + organizationId: resolvedOrganization?.organizationId ?? null, redirectUri: consumedPendingRequest.redirectUri, scope: consumedPendingRequest.scope, nonce: consumedPendingRequest.nonce, @@ -156,9 +158,10 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( zkKeyHash: hasZk ? keyHashFromClient : undefined, zkKeyKind: hasZk ? deliveredKeyKind : undefined, zkKeyVersion: hasZk ? keyDeliveryVersion : undefined, + requireOrganizationSelection: consumedPendingRequest.requireOrganizationSelection, }); - if (sessionId) { + if (sessionId && resolvedOrganization) { await updateSession(context, sessionId, { ...sessionData, organizationId: resolvedOrganization.organizationId, @@ -166,7 +169,7 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( }); } - if (sessionOrganizationId !== resolvedOrganization.organizationId) { + if (resolvedOrganization && sessionOrganizationId !== resolvedOrganization.organizationId) { await logAuditEvent(context, { eventType: "ORGANIZATION_SWITCHED", method: request.method || "POST", diff --git a/packages/api/src/controllers/user/oauthEndpoints.test.ts b/packages/api/src/controllers/user/oauthEndpoints.test.ts index 6090a99..bd6f802 100644 --- a/packages/api/src/controllers/user/oauthEndpoints.test.ts +++ b/packages/api/src/controllers/user/oauthEndpoints.test.ts @@ -331,6 +331,54 @@ test("token redeems public authorization code clients with none auth", async () } }); +test("token redeems orgless authorization code clients for multi-organization users", async () => { + const { context, cleanup } = await createContext(); + try { + await createUser(context); + await createUserOrganization(context, "first-org"); + await createUserOrganization(context, "second-org"); + await context.db.insert(clients).values({ + clientId: "orgless-client", + name: "Orgless", + type: "public", + tokenEndpointAuthMethod: "none", + redirectUris: ["https://app.example.com/callback"], + postLogoutRedirectUris: [], + requireOrganizationSelection: false, + }); + const codeVerifier = await createAuthorizationCode(context, "orgless-client", "orgless-code"); + const request = createRequest({ + method: "POST", + url: "/token", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: "orgless-code", + redirect_uri: "https://app.example.com/callback", + client_id: "orgless-client", + code_verifier: codeVerifier, + }).toString(), + }); + const response = createResponse(); + + await postToken(context, request, response); + + const json = response.json as Record; + assert.equal(response.statusCode, 200); + assert.equal(typeof json.id_token, "string"); + assert.equal(typeof json.access_token, "string"); + const idTokenClaims = await verifyJWT(context, json.id_token as string, "orgless-client"); + const accessTokenClaims = await verifyJWT( + context, + json.access_token as string, + "orgless-client" + ); + assert.equal(idTokenClaims.org_id, undefined); + assert.equal(accessTokenClaims.org_id, undefined); + } finally { + await cleanup(); + } +}); + test("token rejects explicit unbound refresh tokens for public clients", 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 e14f27f..a927aab 100644 --- a/packages/api/src/controllers/user/token.ts +++ b/packages/api/src/controllers/user/token.ts @@ -526,30 +526,34 @@ export const postToken = withRateLimit("token")( ); if (!client) throw new UnauthorizedClientError("Unknown client"); + const directPermissionRows = await context.db.query.userPermissions.findMany({ + where: (table, { eq }) => eq(table.userSub, user.sub), + }); + const directPermissions = directPermissionRows.map((row) => row.permissionKey); const sessionOrganizationId = typeof (sessionData as SessionData).organizationId === "string" ? (sessionData as SessionData).organizationId : undefined; - const { organizationId, organizationSlug } = await resolveAuthorizationOrganizationContext( - context, - user.sub, - { sessionOrganizationId } - ); - 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(); - if (sessionOrganizationId !== organizationId) { + let organizationId: string | undefined; + let organizationSlug: string | null | undefined; + let roleKeys: string[] = []; + let uniquePermissions = Array.from(new Set(directPermissions)).sort(); + if (client.requireOrganizationSelection) { + const resolvedOrganization = await resolveAuthorizationOrganizationContext( + context, + user.sub, + { sessionOrganizationId } + ); + organizationId = resolvedOrganization.organizationId; + organizationSlug = resolvedOrganization.organizationSlug; + const { roleKeys: resolvedRoleKeys, permissions: organizationPermissions } = + await getUserOrgAccess(context, user.sub, organizationId); + roleKeys = resolvedRoleKeys; + uniquePermissions = Array.from( + new Set([...organizationPermissions, ...directPermissions]) + ).sort(); + } + if (organizationId && sessionOrganizationId !== organizationId) { await updateSession(context, rotated.sessionId, { ...(sessionData as SessionData), organizationId, @@ -831,26 +835,32 @@ export const postToken = withRateLimit("token")( throw new InvalidGrantError("User not found"); } - const { getUserOrgAccess, resolveOrganizationContext } = await import("../../models/rbac.ts"); - const { organizationId, organizationSlug } = await resolveOrganizationContext( - context, - user.sub, - authCode.organizationId || undefined - ); - 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 directPermissions = directPermissionRows.map((row) => row.permissionKey); + let organizationId: string | undefined; + let organizationSlug: string | null | undefined; + let roleKeys: string[] = []; + let uniquePermissions = Array.from(new Set(directPermissions)).sort(); + if (authCode.requireOrganizationSelection || authCode.organizationId) { + const { getUserOrgAccess, resolveOrganizationContext } = await import( + "../../models/rbac.ts" + ); + const resolvedOrganization = await resolveOrganizationContext( + context, + user.sub, + authCode.organizationId || undefined + ); + organizationId = resolvedOrganization.organizationId; + organizationSlug = resolvedOrganization.organizationSlug; + const { roleKeys: resolvedRoleKeys, permissions: organizationPermissions } = + await getUserOrgAccess(context, user.sub, organizationId); + roleKeys = resolvedRoleKeys; + uniquePermissions = Array.from( + new Set([...organizationPermissions, ...directPermissions]) + ).sort(); + } const codeConsumed = await (await import("../../models/authCodes.ts")).consumeAuthCode( context, diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index d149954..db88068 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -102,6 +102,7 @@ export const clients = pgTable("clients", { keyDeliveryVersion: text("key_delivery_version").default("v2").notNull(), deliveredKeyKind: text("delivered_key_kind").default("client_app_key").notNull(), clientKeyScope: text("client_key_scope").default("organization").notNull(), + requireOrganizationSelection: boolean("require_organization_selection").default(true).notNull(), allowedJweAlgs: text("allowed_jwe_algs").array().default([]).notNull(), allowedJweEncs: text("allowed_jwe_encs").array().default([]).notNull(), redirectUris: text("redirect_uris").array().default([]).notNull(), @@ -553,6 +554,7 @@ export const authCodes = pgTable("auth_codes", { zkKeyHash: text("zk_key_hash"), zkKeyKind: text("zk_key_kind"), zkKeyVersion: text("zk_key_version"), + requireOrganizationSelection: boolean("require_organization_selection").default(true).notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }); @@ -597,6 +599,7 @@ export const pendingAuth = pgTable("pending_auth", { keyDeliveryVersion: text("key_delivery_version").default("v2").notNull(), deliveredKeyKind: text("delivered_key_kind").default("client_app_key").notNull(), clientKeyScope: text("client_key_scope").default("organization").notNull(), + requireOrganizationSelection: boolean("require_organization_selection").default(true).notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), expiresAt: timestamp("expires_at").notNull(), userSub: text("user_sub").references(() => users.sub, { diff --git a/packages/api/src/models/authCodes.ts b/packages/api/src/models/authCodes.ts index d0e0eee..73f4f32 100644 --- a/packages/api/src/models/authCodes.ts +++ b/packages/api/src/models/authCodes.ts @@ -44,6 +44,7 @@ export async function createAuthCode( zkKeyHash?: string | undefined; zkKeyKind?: string | undefined; zkKeyVersion?: string | undefined; + requireOrganizationSelection?: boolean; } ) { try { @@ -65,6 +66,7 @@ export async function createAuthCode( zkKeyHash: data.zkKeyHash, zkKeyKind: data.zkKeyKind, zkKeyVersion: data.zkKeyVersion, + requireOrganizationSelection: data.requireOrganizationSelection ?? true, createdAt: new Date(), }); } catch (error) { diff --git a/packages/api/src/models/authorize.ts b/packages/api/src/models/authorize.ts index 17a784f..5bb153b 100644 --- a/packages/api/src/models/authorize.ts +++ b/packages/api/src/models/authorize.ts @@ -18,6 +18,7 @@ export async function createPendingAuth( keyDeliveryVersion?: string; deliveredKeyKind?: string; clientKeyScope?: string; + requireOrganizationSelection?: boolean; userSub?: string; organizationId?: string; origin: string; @@ -38,6 +39,7 @@ export async function createPendingAuth( keyDeliveryVersion: data.keyDeliveryVersion ?? "v2", deliveredKeyKind: data.deliveredKeyKind ?? "client_app_key", clientKeyScope: data.clientKeyScope ?? "organization", + requireOrganizationSelection: data.requireOrganizationSelection ?? true, userSub: data.userSub, organizationId: data.organizationId, origin: data.origin, diff --git a/packages/api/src/models/clients.test.ts b/packages/api/src/models/clients.test.ts index 3e20b24..99e64e4 100644 --- a/packages/api/src/models/clients.test.ts +++ b/packages/api/src/models/clients.test.ts @@ -233,14 +233,17 @@ test("createClient and updateClient enforce key delivery version and delivered k assert.equal(created.keyDeliveryVersion, "v2"); assert.equal(created.deliveredKeyKind, "client_app_key"); assert.equal(created.clientKeyScope, "organization"); + assert.equal(created.requireOrganizationSelection, true); const listedCreated = await listClients(context, { search: "v2-client" }); assert.equal(listedCreated.clients[0]?.clientKeyScope, "organization"); + assert.equal(listedCreated.clients[0]?.requireOrganizationSelection, true); await updateClient(context, "v2-client", { keyDeliveryVersion: "v1-drk", deliveredKeyKind: "root_key", clientKeyScope: "account", + requireOrganizationSelection: false, }); const updated = await getClient(context, "v2-client"); @@ -248,9 +251,11 @@ test("createClient and updateClient enforce key delivery version and delivered k assert.equal(updated.keyDeliveryVersion, "v1-drk"); assert.equal(updated.deliveredKeyKind, "root_key"); assert.equal(updated.clientKeyScope, "account"); + assert.equal(updated.requireOrganizationSelection, false); const listedUpdated = await listClients(context, { search: "v2-client" }); assert.equal(listedUpdated.clients[0]?.clientKeyScope, "account"); + assert.equal(listedUpdated.clients[0]?.requireOrganizationSelection, false); await assert.rejects( () => diff --git a/packages/api/src/models/clients.ts b/packages/api/src/models/clients.ts index 9939c7e..4739ab6 100644 --- a/packages/api/src/models/clients.ts +++ b/packages/api/src/models/clients.ts @@ -73,6 +73,7 @@ export async function listClients( keyDeliveryVersion: clients.keyDeliveryVersion, deliveredKeyKind: clients.deliveredKeyKind, clientKeyScope: clients.clientKeyScope, + requireOrganizationSelection: clients.requireOrganizationSelection, allowedJweAlgs: clients.allowedJweAlgs, allowedJweEncs: clients.allowedJweEncs, redirectUris: clients.redirectUris, @@ -108,6 +109,7 @@ export async function listClients( keyDeliveryVersion: clients.keyDeliveryVersion, deliveredKeyKind: clients.deliveredKeyKind, clientKeyScope: clients.clientKeyScope, + requireOrganizationSelection: clients.requireOrganizationSelection, allowedJweAlgs: clients.allowedJweAlgs, allowedJweEncs: clients.allowedJweEncs, redirectUris: clients.redirectUris, @@ -164,6 +166,7 @@ export async function createClient( keyDeliveryVersion?: "v1-drk" | "v2"; deliveredKeyKind?: "root_key" | "client_app_key"; clientKeyScope?: "account" | "organization"; + requireOrganizationSelection?: boolean; allowedJweAlgs?: string[]; allowedJweEncs?: string[]; redirectUris?: string[]; @@ -214,6 +217,7 @@ export async function createClient( keyDeliveryVersion, deliveredKeyKind, clientKeyScope, + requireOrganizationSelection: data.requireOrganizationSelection ?? true, allowedJweAlgs: data.allowedJweAlgs ?? [], allowedJweEncs: data.allowedJweEncs ?? [], redirectUris: data.redirectUris ?? [], diff --git a/packages/user-ui/src/App.css b/packages/user-ui/src/App.css index cd72108..ffdd7d0 100644 --- a/packages/user-ui/src/App.css +++ b/packages/user-ui/src/App.css @@ -1214,10 +1214,21 @@ button .loading-spinner { .authorize-organization-option { cursor: pointer; transition: + background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; } +.authorize-organization-option[data-selected="true"] { + background: color-mix( + in srgb, + var(--da-primary, var(--primary-500)) 12%, + var(--da-input-bg, #fff) + ); + border-color: var(--da-primary, var(--primary-500)); + box-shadow: 0 0 0 1px var(--da-primary, var(--primary-500)); +} + .authorize-organization-option:hover, .authorize-organization-option:focus-within { border-color: var(--da-primary, var(--primary-500)); @@ -1231,6 +1242,11 @@ button .loading-spinner { flex-shrink: 0; } +.authorize-organization-option input:checked { + outline: 2px solid color-mix(in srgb, var(--da-primary, var(--primary-500)) 35%, transparent); + outline-offset: 2px; +} + .authorize-organization-option-text { display: flex; flex-direction: column; @@ -1243,3 +1259,12 @@ button .loading-spinner { background: var(--da-input-bg, var(--gray-100)); border-color: var(--da-border, var(--gray-300)); } + +[data-da-theme="dark"] .authorize-organization-option[data-selected="true"] { + background: color-mix( + in srgb, + var(--da-primary, var(--primary-500)) 18%, + var(--da-input-bg, var(--gray-100)) + ); + border-color: var(--da-primary, var(--primary-500)); +} diff --git a/packages/user-ui/src/App.tsx b/packages/user-ui/src/App.tsx index 2df48f0..fe8f0f4 100644 --- a/packages/user-ui/src/App.tsx +++ b/packages/user-ui/src/App.tsx @@ -56,6 +56,7 @@ interface AuthRequest { keyDeliveryVersion?: "v1-drk" | "v2"; deliveredKeyKind?: "root_key" | "client_app_key"; clientKeyScope?: "account" | "organization"; + requireOrganizationSelection?: boolean; clientId?: string; redirectUri?: string; state?: string; @@ -163,6 +164,7 @@ function AppContent() { params.get("delivered_key_kind") === "root_key" ? "root_key" : "client_app_key"; const clientKeyScope = params.get("client_key_scope") === "account" ? "account" : "organization"; + const requireOrganizationSelection = params.get("require_organization_selection") !== "0"; const clientId = params.get("client_id") || undefined; const redirectUri = params.get("redirect_uri") || undefined; const state = params.get("state") || undefined; @@ -180,6 +182,7 @@ function AppContent() { current.keyDeliveryVersion === keyDeliveryVersion && current.deliveredKeyKind === deliveredKeyKind && current.clientKeyScope === clientKeyScope && + current.requireOrganizationSelection === requireOrganizationSelection && current.clientId === clientId && current.redirectUri === redirectUri && current.state === state && @@ -199,6 +202,7 @@ function AppContent() { keyDeliveryVersion, deliveredKeyKind, clientKeyScope, + requireOrganizationSelection, clientId, redirectUri, state, diff --git a/packages/user-ui/src/components/Authorize.test.js b/packages/user-ui/src/components/Authorize.test.js index d1c0848..cc6ceac 100644 --- a/packages/user-ui/src/components/Authorize.test.js +++ b/packages/user-ui/src/components/Authorize.test.js @@ -144,13 +144,15 @@ test("Authorize stores unlocked ARK in memory only for ZK finalization", () => { }); test("multi-organization authorize keeps the organization picker visible", () => { - const summaryExpression = source.match(/const showOrganizationSummary = ([^;]+);/); - - assert.ok(summaryExpression); - assert.equal( - summaryExpression[1], - "activeOrganizations.length === 1 || selectedOrganizationLocked" - ); + const summaryIndex = source.indexOf("const showOrganizationSummary ="); + const summaryEnd = source.indexOf(";", summaryIndex); + const summaryExpression = source.slice(summaryIndex, summaryEnd); + + assert.notEqual(summaryIndex, -1); + assert.notEqual(summaryExpression.indexOf("requireOrganizationSelection"), -1); + assert.notEqual(summaryExpression.indexOf("activeOrganizations.length === 1"), -1); + assert.notEqual(summaryExpression.indexOf("selectedOrganizationLocked"), -1); + assert.notEqual(source.indexOf("{requireOrganizationSelection && ("), -1); }); test("Authorize filters unlock choices through enterprise policy", () => { diff --git a/packages/user-ui/src/components/Authorize.tsx b/packages/user-ui/src/components/Authorize.tsx index 85adf37..60e8702 100644 --- a/packages/user-ui/src/components/Authorize.tsx +++ b/packages/user-ui/src/components/Authorize.tsx @@ -66,6 +66,7 @@ interface AuthorizeProps { keyDeliveryVersion?: "v1-drk" | "v2"; deliveredKeyKind?: "root_key" | "client_app_key"; clientKeyScope?: "account" | "organization"; + requireOrganizationSelection?: boolean; clientId?: string; redirectUri?: string; state?: string; @@ -124,6 +125,7 @@ export default function Authorize({ const appName = authRequest.clientName || "Application"; const explicitOrganizationId = authRequest.organizationId || ""; const sessionOrganizationId = sessionData.organizationId || ""; + const requireOrganizationSelection = authRequest.requireOrganizationSelection !== false; const hasZkDeliveryScope = authRequest.hasZk && new URL(window.location.href).searchParams.has("zk_pub"); const activeOrganizations = organizations.filter((organization) => @@ -132,9 +134,12 @@ export default function Authorize({ const selectedOrganization = activeOrganizations.find( (organization) => organization.organizationId === selectedOrganizationId ); - const noActiveOrganizations = !organizationsLoading && activeOrganizations.length === 0; + const noActiveOrganizations = + requireOrganizationSelection && !organizationsLoading && activeOrganizations.length === 0; const selectedOrganizationLocked = !!explicitOrganizationId; - const showOrganizationSummary = activeOrganizations.length === 1 || selectedOrganizationLocked; + const showOrganizationSummary = + requireOrganizationSelection && + (activeOrganizations.length === 1 || selectedOrganizationLocked); const keyLockedForZk = authRequest.hasZk && !keyUnlocked; const allUnlockOptions: Array<{ value: UnlockMethod; label: string }> = [ { value: "password", label: "Password" }, @@ -168,6 +173,12 @@ export default function Authorize({ }, [authRequest.hasZk, generateZkKeyPair]); useEffect(() => { + if (!requireOrganizationSelection) { + setOrganizations([]); + setOrganizationsLoading(false); + setSelectedOrganizationId(""); + return; + } let cancelled = false; setOrganizationsLoading(true); apiService @@ -207,7 +218,7 @@ export default function Authorize({ return () => { cancelled = true; }; - }, [explicitOrganizationId, sessionOrganizationId]); + }, [explicitOrganizationId, requireOrganizationSelection, sessionOrganizationId]); useEffect(() => { if (!authRequest.hasZk || keyUnlocked) { @@ -328,7 +339,9 @@ export default function Authorize({ const keyId = await resolveAccountKeyId(); const scopedOrganizationId = - authRequest.clientKeyScope === "account" ? undefined : selectedOrganizationId || undefined; + authRequest.clientKeyScope === "account" || !requireOrganizationSelection + ? undefined + : selectedOrganizationId || undefined; const cak = await cryptoService.deriveClientAppKey(ark, { sub: sessionData.sub, keyId, @@ -372,7 +385,9 @@ export default function Authorize({ approve: true, drkHash: "drkHash" in delivery ? delivery.drkHash : undefined, zkKeyHash: "zkKeyHash" in delivery ? delivery.zkKeyHash : undefined, - organizationId: selectedOrganizationId || undefined, + organizationId: requireOrganizationSelection + ? selectedOrganizationId || undefined + : undefined, }); const redirectUrl = new URL(authResponse.redirectUrl); redirectUrl.hash = `${delivery.fragmentName}=${encodeURIComponent(delivery.jwe)}`; @@ -538,7 +553,12 @@ export default function Authorize({ setError("Your account is not a member of any active organization."); return; } - if (approve && activeOrganizations.length > 1 && !selectedOrganizationId) { + if ( + approve && + requireOrganizationSelection && + activeOrganizations.length > 1 && + !selectedOrganizationId + ) { setError("Choose which organization to use for this sign-in."); return; } @@ -603,7 +623,9 @@ export default function Authorize({ const authResponse = await apiService.authorize({ requestId: authRequest.requestId, approve, - organizationId: selectedOrganizationId || undefined, + organizationId: requireOrganizationSelection + ? selectedOrganizationId || undefined + : undefined, }); logger.info({ requestId: authRequest.requestId, approve }, "[Authorize] finalize without ZK"); window.location.href = authResponse.redirectUrl; @@ -973,65 +995,69 @@ export default function Authorize({ -
-

Organization

- {organizationsLoading ? ( -

Loading organizations...

- ) : noActiveOrganizations ? ( -

- Your account is not a member of any active organization. -

- ) : showOrganizationSummary ? ( -
- O -
- - {selectedOrganization?.name || "Selected organization"} - - - {selectedOrganizationLocked - ? `${appName} requested this organization for sign-in.` - : "This organization will be used for this sign-in."} - + {requireOrganizationSelection && ( +
+

Organization

+ {organizationsLoading ? ( +

Loading organizations...

+ ) : noActiveOrganizations ? ( +

+ Your account is not a member of any active organization. +

+ ) : showOrganizationSummary ? ( +
+ O +
+ + {selectedOrganization?.name || "Selected organization"} + + + {selectedOrganizationLocked + ? `${appName} requested this organization for sign-in.` + : "This organization will be used for this sign-in."} + +
-
- ) : ( -
- Choose which organization to use for this sign-in. -
- {activeOrganizations.map((organization) => { - const roles = organization.roles - ?.map((role) => role.name || role.key) - .filter((role): role is string => !!role); - return ( -
+ + ); + })} +
+ + )} +
+ )}

Permissions