Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/admin-ui/src/pages/ClientEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -993,6 +996,37 @@ export default function ClientEdit({ mode = "edit" }: ClientEditProps) {
stay stable across organizations.
</FieldHint>
</FormField>

<FormField
label={
<FieldLabel
title="Require Organization Selection"
tooltip="Controls whether hosted authorization must bind this client to a selected organization."
/>
}
>
<Select
value={form.requireOrganizationSelection ? "yes" : "no"}
onValueChange={(v) =>
setForm((f) => ({
...f,
requireOrganizationSelection: v === "yes",
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
<FieldHint>
Disable this for generic OIDC clients that do not understand organization
context.
</FieldHint>
</FormField>
</FormGrid>
</CardContent>
</Card>
Expand Down
2 changes: 2 additions & 0 deletions packages/admin-ui/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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[];
Expand Down
5 changes: 5 additions & 0 deletions packages/api/drizzle/0039_client_organization_selection.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions packages/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
2 changes: 2 additions & 0 deletions packages/api/src/controllers/admin/clientCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down Expand Up @@ -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()),
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/controllers/admin/clientUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/controllers/admin/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/controllers/user/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand All @@ -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);
Expand Down
27 changes: 15 additions & 12 deletions packages/api/src/controllers/user/authorizeFinalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -156,17 +158,18 @@ 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,
organizationSlug: resolvedOrganization.organizationSlug || undefined,
});
}

if (sessionOrganizationId !== resolvedOrganization.organizationId) {
if (resolvedOrganization && sessionOrganizationId !== resolvedOrganization.organizationId) {
await logAuditEvent(context, {
eventType: "ORGANIZATION_SWITCHED",
method: request.method || "POST",
Expand Down
48 changes: 48 additions & 0 deletions packages/api/src/controllers/user/oauthEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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 {
Expand Down
84 changes: 47 additions & 37 deletions packages/api/src/controllers/user/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
});

Expand Down Expand Up @@ -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, {
Expand Down
Loading
Loading