diff --git a/packages/admin-ui/src/App.tsx b/packages/admin-ui/src/App.tsx
index f921b21d..377db1e1 100644
--- a/packages/admin-ui/src/App.tsx
+++ b/packages/admin-ui/src/App.tsx
@@ -28,6 +28,7 @@ import Keys from "./pages/Keys";
import NotFound from "./pages/NotFound";
import OrganizationCreate from "./pages/OrganizationCreate";
import OrganizationEdit from "./pages/OrganizationEdit";
+import OrganizationFederationSetup from "./pages/OrganizationFederationSetup";
import Organizations from "./pages/Organizations";
import Permissions from "./pages/Permissions";
import Preview from "./pages/Preview";
@@ -312,6 +313,14 @@ const App = () => {
}
/>
+
+
+
+ }
+ />
([]);
+ const [organizations, setOrganizations] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
@@ -250,6 +256,21 @@ export default function FederationConnections() {
loadConnections();
}, [loadConnections]);
+ useEffect(() => {
+ let cancelled = false;
+ adminApiService
+ .getOrganizationsPaged({ page: 1, limit: 100, sortBy: "name", sortOrder: "asc" })
+ .then((response) => {
+ if (!cancelled) setOrganizations(response.organizations);
+ })
+ .catch(() => {
+ if (!cancelled) setOrganizations([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedSearch(searchQuery);
@@ -468,6 +489,7 @@ export default function FederationConnections() {
sortOrder={sortOrder}
onToggle={() => toggleSort("issuer")}
/>
+ Organization
Domains
Status
{connection.issuer}
+
+ {connection.organizationName ? (
+
+
{connection.organizationName}
+ {connection.organizationSlug ? (
+
{connection.organizationSlug}
+ ) : null}
+
+ ) : (
+ Unassigned
+ )}
+
{connection.domains.length > 0 ? connection.domains.join(", ") : "None"}
@@ -562,6 +596,30 @@ export default function FederationConnections() {
}
/>
+ Organization{editing ? "" : " *"}}>
+
+ setForm((current) => ({ ...current, organizationId: value }))
+ }
+ >
+
+
+
+
+ {organizations.map((organization) => (
+
+ {organization.slug
+ ? `${organization.name} (${organization.slug})`
+ : organization.name}
+
+ ))}
+
+
+
Issuer}>
diff --git a/packages/admin-ui/src/pages/OrganizationEdit.module.css b/packages/admin-ui/src/pages/OrganizationEdit.module.css
index 62a5bf28..80e7d709 100644
--- a/packages/admin-ui/src/pages/OrganizationEdit.module.css
+++ b/packages/admin-ui/src/pages/OrganizationEdit.module.css
@@ -93,3 +93,53 @@
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
}
+
+.roleCatalog {
+ display: grid;
+ gap: 12px;
+}
+
+.roleCatalogItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+}
+
+.roleCatalogItem strong {
+ display: block;
+ color: hsl(var(--foreground));
+}
+
+.enterpriseGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 16px;
+}
+
+.enterprisePanel {
+ display: grid;
+ align-content: start;
+ gap: 12px;
+ padding: 16px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+}
+
+.enterpriseItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-width: 0;
+ padding: 10px 0;
+ border-top: 1px solid hsl(var(--border));
+}
+
+.enterpriseItem span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
diff --git a/packages/admin-ui/src/pages/OrganizationEdit.tsx b/packages/admin-ui/src/pages/OrganizationEdit.tsx
index 12c59184..41ce6d6b 100644
--- a/packages/admin-ui/src/pages/OrganizationEdit.tsx
+++ b/packages/admin-ui/src/pages/OrganizationEdit.tsx
@@ -6,7 +6,6 @@ import CheckboxRow from "@/components/form/checkbox-row";
import FormActions from "@/components/layout/form-actions";
import { FormField, FormGrid } from "@/components/layout/form-grid";
import PageHeader from "@/components/layout/page-header";
-import Stack from "@/components/layout/stack";
import RowActions from "@/components/row-actions";
import MutedText from "@/components/text/muted-text";
import { Badge } from "@/components/ui/badge";
@@ -39,10 +38,13 @@ import {
TableRow,
} from "@/components/ui/table";
import tableStyles from "@/components/ui/table.module.css";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import adminApiService, {
+ type FederationConnection,
type Organization,
type OrganizationMember,
type Role,
+ type ScimBearerToken,
type User,
} from "@/services/api";
import styles from "./OrganizationEdit.module.css";
@@ -61,6 +63,11 @@ type MemberPayload = Partial & {
const normalizeOrganizationId = (organization: OrganizationPayload, fallbackId: string): string =>
organization.organizationId || organization.id || fallbackId;
+const belongsToOrganization = (
+ item: { organizationId?: string | null },
+ organizationId: string
+): boolean => !item.organizationId || item.organizationId === organizationId;
+
const normalizeMembers = (members: unknown): OrganizationMember[] => {
if (!Array.isArray(members)) {
return [];
@@ -106,6 +113,8 @@ export default function OrganizationEdit() {
const [forceOtp, setForceOtp] = useState(false);
const [members, setMembers] = useState([]);
const [allRoles, setAllRoles] = useState([]);
+ const [federationConnections, setFederationConnections] = useState([]);
+ const [scimTokens, setScimTokens] = useState([]);
const [addUserOpen, setAddUserOpen] = useState(false);
const [addUserSearch, setAddUserSearch] = useState("");
const [debouncedAddUserSearch, setDebouncedAddUserSearch] = useState("");
@@ -134,9 +143,13 @@ export default function OrganizationEdit() {
try {
setLoading(true);
setError(null);
- const [orgData, rolesData] = await Promise.all([
+ const [orgData, rolesData, federationData, scimData] = await Promise.all([
adminApiService.getOrganization(organizationId),
adminApiService.getRoles(),
+ adminApiService
+ .getFederationConnections({ organizationId, limit: 100 })
+ .catch(() => ({ connections: [] })),
+ adminApiService.getScimTokens().catch(() => ({ tokens: [] })),
]);
const org = orgData as OrganizationPayload;
const resolvedOrganizationId = normalizeOrganizationId(org, organizationId);
@@ -148,6 +161,14 @@ export default function OrganizationEdit() {
setSlug(org.slug);
setForceOtp(org.forceOtp === true);
setAllRoles(rolesData);
+ setFederationConnections(
+ federationData.connections.filter((connection) =>
+ belongsToOrganization(connection, resolvedOrganizationId)
+ )
+ );
+ setScimTokens(
+ scimData.tokens.filter((token) => belongsToOrganization(token, resolvedOrganizationId))
+ );
const orgMembers = normalizeMembers(org.members);
if (orgMembers.length > 0) {
setMembers(orgMembers);
@@ -425,135 +446,272 @@ export default function OrganizationEdit() {
{error && {error} }
-
-
-
- Organization Details
-
-
-
- Organization ID}>
-
-
- Organization Name *}>
- setName(e.target.value)}
- disabled={submitting}
- />
-
- Slug}>
- setSlug(e.target.value)}
- disabled={submitting}
- />
-
- Status}>
- member.status === "active").length} active members`}
- readOnly
- />
-
- Security}>
- setForceOtp(checked)}
- />
-
-
-
-
-
-
-
- Members
- Manage organization members and roles
-
-
-
-
-
- Add User
-
-
-
- {members.length === 0 ? (
-
- No members found for this organization
-
- ) : (
-
-
-
- Member
- Status
- Roles
-
-
-
-
- {members.map((member) => (
-
-
- openEditRoles(member)}
- >
-
- {member.email || member.userSub}
-
-
-
-
-
- {member.status}
-
-
-
-
- {member.roles.length === 0 ? (
- No roles
- ) : (
- member.roles.map((role) => (
-
- {role.name}
-
- ))
- )}
-
-
-
- openEditRoles(member),
- },
- {
- key: "remove-member",
- label: "Remove from organization",
- destructive: true,
- disabled: submitting,
- onClick: () => removeMemberFromOrganization(member),
- },
- ]}
- />
-
+
+
+ Members
+ Roles
+ Enterprise Connections
+ Security
+ Audit
+
+
+
+
+
+ Members
+ Manage organization members and roles
+
+
+
+
+
+ Add User
+
+
+
+ {members.length === 0 ? (
+
+ No members found for this organization
+
+ ) : (
+
+
+
+ Member
+ Status
+ Roles
+
+
+
+ {members.map((member) => (
+
+
+ openEditRoles(member)}
+ >
+
+ {member.email || member.userSub}
+
+
+
+
+
+ {member.status}
+
+
+
+
+ {member.roles.length === 0 ? (
+ No roles
+ ) : (
+ member.roles.map((role) => (
+
+ {role.name}
+
+ ))
+ )}
+
+
+
+ openEditRoles(member),
+ },
+ {
+ key: "remove-member",
+ label: "Remove from organization",
+ destructive: true,
+ disabled: submitting,
+ onClick: () => removeMemberFromOrganization(member),
+ },
+ ]}
+ />
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+ Roles
+ Review role templates available to this organization
+
+
+ {allRoles.length === 0 ? (
+ No roles are available.
+ ) : (
+
+ {allRoles.map((role) => (
+
+
+ {role.name}
+ {role.key}
+
+
+ {role.assignable ? (
+ Assignable
+ ) : null}
+ {role.defaultMember || role.default_member ? (
+ Default member
+ ) : null}
+ {role.defaultCreator || role.default_creator ? (
+ Default creator
+ ) : null}
+
+
))}
-
-
- )}
-
-
-
+
+ )}
+
+
+
+
+
+
+
+ Enterprise Connections
+ Organization-owned SSO and SCIM surfaces
+
+
+
+
+
SSO connections
+
+ {federationConnections.length === 0
+ ? "No federation connections are linked to this organization yet."
+ : `${federationConnections.length} federation connection${
+ federationConnections.length === 1 ? "" : "s"
+ }`}
+
+ {federationConnections.map((connection) => (
+
+
+ navigate(
+ `/organizations/${encodeURIComponent(
+ organization.organizationId
+ )}/federation/${encodeURIComponent(connection.id)}`
+ )
+ }
+ >
+ {connection.name}
+
+
+ {connection.enabled ? "Enabled" : "Disabled"}
+
+
+ ))}
+
navigate("/federation")}
+ >
+ Open Federation
+
+
+
+
SCIM provisioning
+
+ {scimTokens.length === 0
+ ? "No SCIM tokens are linked to this organization yet."
+ : `${scimTokens.length} SCIM token${scimTokens.length === 1 ? "" : "s"}`}
+
+ {scimTokens.map((token) => (
+
+ {token.name}
+
+ {token.revokedAt ? "Revoked" : "Active"}
+
+
+ ))}
+
navigate("/scim")}
+ >
+ Open SCIM Tokens
+
+
+
+
+
+
+
+
+
+
+ Organization Details
+
+
+
+ Organization ID}>
+
+
+ Organization Name *}>
+ setName(e.target.value)}
+ disabled={submitting}
+ />
+
+ Slug}>
+ setSlug(e.target.value)}
+ disabled={submitting}
+ />
+
+ Status}>
+ member.status === "active").length} active members`}
+ readOnly
+ />
+
+ Security}>
+ setForceOtp(checked)}
+ />
+
+
+
+
+
+
+
+
+
+ Audit
+ Organization-scoped audit events will appear here.
+
+
+ navigate("/audit-logs")}>
+ Open Audit Logs
+
+
+
+
+
diff --git a/packages/admin-ui/src/pages/OrganizationFederationSetup.module.css b/packages/admin-ui/src/pages/OrganizationFederationSetup.module.css
new file mode 100644
index 00000000..c06c306e
--- /dev/null
+++ b/packages/admin-ui/src/pages/OrganizationFederationSetup.module.css
@@ -0,0 +1,62 @@
+.copyRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.copyRow input {
+ flex: 1;
+}
+
+.dnsRecord {
+ display: grid;
+ gap: 12px;
+}
+
+.dnsField {
+ display: grid;
+ gap: 6px;
+}
+
+.domainList {
+ display: grid;
+ gap: 10px;
+}
+
+.domainItem {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+}
+
+.domainMeta {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.domainName {
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+.domainActions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.addDomainRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.addDomainRow input {
+ flex: 1;
+}
diff --git a/packages/admin-ui/src/pages/OrganizationFederationSetup.tsx b/packages/admin-ui/src/pages/OrganizationFederationSetup.tsx
new file mode 100644
index 00000000..690c7c5c
--- /dev/null
+++ b/packages/admin-ui/src/pages/OrganizationFederationSetup.tsx
@@ -0,0 +1,337 @@
+import { ArrowLeft, Copy, Plus, RefreshCw, Trash2 } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import ErrorBanner from "@/components/feedback/error-banner";
+import { FormField, FormGrid } from "@/components/layout/form-grid";
+import PageHeader from "@/components/layout/page-header";
+import MutedText from "@/components/text/muted-text";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import adminApiService, {
+ type FederationConnection,
+ type FederationConnectionDomain,
+ type Organization,
+} from "@/services/api";
+import styles from "./OrganizationFederationSetup.module.css";
+
+function recordNameForDomain(domain: FederationConnectionDomain): string {
+ return domain.recordName || `_darkauth-verification.${domain.domain}`;
+}
+
+function recordValueForDomain(domain: FederationConnectionDomain): string {
+ return domain.recordValue || "";
+}
+
+function statusVariant(
+ status: FederationConnectionDomain["verificationStatus"]
+): "default" | "secondary" | "outline" {
+ if (status === "verified") return "default";
+ if (status === "failed") return "outline";
+ return "secondary";
+}
+
+function CopyField({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}
+
+
+ navigator.clipboard.writeText(value)}
+ >
+
+ Copy
+
+
+
+ );
+}
+
+export default function OrganizationFederationSetup() {
+ const { organizationId, connectionId } = useParams<{
+ organizationId: string;
+ connectionId: string;
+ }>();
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [organization, setOrganization] = useState(null);
+ const [connection, setConnection] = useState(null);
+ const [domains, setDomains] = useState([]);
+ const [publicOrigin, setPublicOrigin] = useState("");
+ const [newDomain, setNewDomain] = useState("");
+ const [addingDomain, setAddingDomain] = useState(false);
+ const [busyDomainId, setBusyDomainId] = useState(null);
+
+ const loadDomains = useCallback(async () => {
+ if (!connectionId) return;
+ const response = await adminApiService.getFederationConnectionDomains(connectionId);
+ setDomains(response.domains);
+ }, [connectionId]);
+
+ const loadData = useCallback(async () => {
+ if (!organizationId || !connectionId) {
+ setError("Organization and connection are required");
+ setLoading(false);
+ return;
+ }
+ try {
+ setLoading(true);
+ setError(null);
+ const [orgData, connectionData, settings] = await Promise.all([
+ adminApiService.getOrganization(organizationId),
+ adminApiService.getFederationConnection(connectionId),
+ adminApiService.getSystemSettings().catch(() => null),
+ ]);
+ setOrganization(orgData);
+ setConnection(connectionData);
+ if (settings) {
+ const origin =
+ (typeof settings.publicOrigin === "string" && settings.publicOrigin) ||
+ (typeof settings.issuer === "string" && settings.issuer) ||
+ "";
+ setPublicOrigin(origin.replace(/\/$/, ""));
+ }
+ try {
+ await loadDomains();
+ } catch {
+ setDomains([]);
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load connection");
+ } finally {
+ setLoading(false);
+ }
+ }, [organizationId, connectionId, loadDomains]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ const callbackUrl = useMemo(() => {
+ if (!publicOrigin) return "";
+ return `${publicOrigin}/api/user/federation/oidc/callback`;
+ }, [publicOrigin]);
+
+ const addDomain = async () => {
+ if (!connectionId || !newDomain.trim()) return;
+ try {
+ setAddingDomain(true);
+ setError(null);
+ const created = await adminApiService.addFederationConnectionDomain(
+ connectionId,
+ newDomain.trim()
+ );
+ setDomains((current) => [...current, created]);
+ setNewDomain("");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to add domain");
+ } finally {
+ setAddingDomain(false);
+ }
+ };
+
+ const verifyDomain = async (domain: FederationConnectionDomain) => {
+ if (!connectionId) return;
+ try {
+ setBusyDomainId(domain.id);
+ setError(null);
+ const updated = await adminApiService.verifyFederationConnectionDomain(
+ connectionId,
+ domain.id
+ );
+ setDomains((current) =>
+ current.map((item) => (item.id === domain.id ? { ...item, ...updated } : item))
+ );
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to verify domain");
+ } finally {
+ setBusyDomainId(null);
+ }
+ };
+
+ const removeDomain = async (domain: FederationConnectionDomain) => {
+ if (!connectionId) return;
+ if (!confirm(`Remove domain "${domain.domain}" from this connection?`)) return;
+ try {
+ setBusyDomainId(domain.id);
+ setError(null);
+ await adminApiService.deleteFederationConnectionDomain(connectionId, domain.id);
+ setDomains((current) => current.filter((item) => item.id !== domain.id));
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to remove domain");
+ } finally {
+ setBusyDomainId(null);
+ }
+ };
+
+ if (loading) return Loading connection...
;
+
+ if (error && !connection) {
+ return (
+
+
{error}
+
+ navigate(
+ organizationId
+ ? `/organizations/${encodeURIComponent(organizationId)}`
+ : "/organizations"
+ )
+ }
+ >
+
+ Back to organization
+
+
+ );
+ }
+
+ if (!connection) return null;
+
+ return (
+
+
+ navigate(
+ organizationId
+ ? `/organizations/${encodeURIComponent(organizationId)}`
+ : "/organizations"
+ )
+ }
+ >
+
+ Back to organization
+
+ }
+ />
+
+ {error && {error} }
+
+
+
+ Identity Provider Callback
+
+ Add this redirect URI to the OIDC application in your identity provider.
+
+
+
+ {callbackUrl ? (
+
+ ) : (
+
+ Public origin is not configured, so the callback URL cannot be displayed.
+
+ )}
+
+ Issuer}>
+
+
+ Client ID}>
+
+
+
+
+
+
+
+
+ Domains
+
+ Email-domain routing only activates after a domain is verified. Add the DNS TXT record
+ below, then retry verification.
+
+
+
+
+
setNewDomain(event.target.value)}
+ disabled={addingDomain}
+ />
+
+
+ Add Domain
+
+
+
+ {domains.length === 0 ? (
+
+ No domains have been added to this connection yet.
+
+ ) : (
+
+ {domains.map((domain) => {
+ const recordName = recordNameForDomain(domain);
+ const recordValue = recordValueForDomain(domain);
+ return (
+
+
+
{domain.domain}
+
+
+ {domain.verificationStatus}
+
+
+ {domain.lastCheckedAt ? (
+
+ Last checked {new Date(domain.lastCheckedAt).toLocaleString()}
+
+ ) : null}
+ {domain.verificationStatus !== "verified" ? (
+
+
+ {recordValue ? (
+
+ ) : (
+
+ The DNS TXT value is shown when the domain is created. Re-add the
+ domain if you no longer have it.
+
+ )}
+
+ ) : null}
+
+
+ verifyDomain(domain)}
+ >
+
+ Retry verification
+
+ removeDomain(domain)}
+ >
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/admin-ui/src/pages/OrganizationRefactorUi.test.js b/packages/admin-ui/src/pages/OrganizationRefactorUi.test.js
new file mode 100644
index 00000000..b2f30883
--- /dev/null
+++ b/packages/admin-ui/src/pages/OrganizationRefactorUi.test.js
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const apiSource = readFileSync(resolve(here, "../services/api.ts"), "utf8");
+const roleCreateSource = readFileSync(resolve(here, "RoleCreate.tsx"), "utf8");
+const roleEditSource = readFileSync(resolve(here, "RoleEdit.tsx"), "utf8");
+const rolesSource = readFileSync(resolve(here, "Roles.tsx"), "utf8");
+const userCreateSource = readFileSync(resolve(here, "UserCreate.tsx"), "utf8");
+const orgEditSource = readFileSync(resolve(here, "OrganizationEdit.tsx"), "utf8");
+
+test("admin role UI supports organization role flags", () => {
+ const source = [apiSource, roleCreateSource, roleEditSource, rolesSource].join("\n");
+ assert.notEqual(source.indexOf("assignable"), -1);
+ assert.notEqual(source.indexOf("defaultMember"), -1);
+ assert.notEqual(source.indexOf("defaultCreator"), -1);
+});
+
+test("admin user create supports organization assignment modes", () => {
+ assert.notEqual(userCreateSource.indexOf("assignmentMode"), -1);
+ assert.notEqual(userCreateSource.indexOf("organizationIds"), -1);
+ assert.notEqual(userCreateSource.indexOf("createPersonalOrganization"), -1);
+ assert.equal(userCreateSource.indexOf("addOrganizationMember"), -1);
+});
+
+test("admin organization detail exposes tabs and enterprise placeholders", () => {
+ assert.notEqual(orgEditSource.indexOf('TabsTrigger value="members"'), -1);
+ assert.notEqual(orgEditSource.indexOf('TabsTrigger value="enterprise"'), -1);
+ assert.notEqual(orgEditSource.indexOf("getFederationConnections"), -1);
+ assert.notEqual(orgEditSource.indexOf("getScimTokens"), -1);
+ assert.notEqual(orgEditSource.indexOf("Open Federation"), -1);
+ assert.notEqual(orgEditSource.indexOf("Open SCIM Tokens"), -1);
+});
diff --git a/packages/admin-ui/src/pages/RoleCreate.tsx b/packages/admin-ui/src/pages/RoleCreate.tsx
index bf233101..eaa7476a 100644
--- a/packages/admin-ui/src/pages/RoleCreate.tsx
+++ b/packages/admin-ui/src/pages/RoleCreate.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import ErrorBanner from "@/components/feedback/error-banner";
+import CheckboxRow from "@/components/form/checkbox-row";
import PermissionGrid from "@/components/group/permission-grid";
import FormActions from "@/components/layout/form-actions";
import { FormField, FormGrid } from "@/components/layout/form-grid";
@@ -19,6 +20,9 @@ export default function RoleCreate() {
const [key, setKey] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
+ const [assignable, setAssignable] = useState(true);
+ const [defaultMember, setDefaultMember] = useState(false);
+ const [defaultCreator, setDefaultCreator] = useState(false);
const [selectedPermissions, setSelectedPermissions] = useState([]);
const [permissions, setPermissions] = useState([]);
const [error, setError] = useState(null);
@@ -63,6 +67,9 @@ export default function RoleCreate() {
key,
name,
description: description.trim() || undefined,
+ assignable,
+ defaultMember,
+ defaultCreator,
permissionKeys: selectedPermissions,
});
navigate("/roles");
@@ -116,6 +123,29 @@ export default function RoleCreate() {
+
+
+
+
+
diff --git a/packages/admin-ui/src/pages/RoleEdit.tsx b/packages/admin-ui/src/pages/RoleEdit.tsx
index 26c43590..55c855ef 100644
--- a/packages/admin-ui/src/pages/RoleEdit.tsx
+++ b/packages/admin-ui/src/pages/RoleEdit.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import ErrorBanner from "@/components/feedback/error-banner";
+import CheckboxRow from "@/components/form/checkbox-row";
import PermissionGrid from "@/components/group/permission-grid";
import FormActions from "@/components/layout/form-actions";
import { FormField, FormGrid } from "@/components/layout/form-grid";
@@ -22,6 +23,9 @@ export default function RoleEdit() {
const [role, setRole] = useState(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
+ const [assignable, setAssignable] = useState(false);
+ const [defaultMember, setDefaultMember] = useState(false);
+ const [defaultCreator, setDefaultCreator] = useState(false);
const [selectedPermissions, setSelectedPermissions] = useState([]);
const [permissions, setPermissions] = useState([]);
@@ -42,6 +46,9 @@ export default function RoleEdit() {
setRole(foundRole);
setName(foundRole.name);
setDescription(foundRole.description || "");
+ setAssignable(foundRole.assignable === true);
+ setDefaultMember(foundRole.defaultMember === true || foundRole.default_member === true);
+ setDefaultCreator(foundRole.defaultCreator === true || foundRole.default_creator === true);
setSelectedPermissions(
foundRole.permissionKeys || foundRole.permissions?.map((p) => p.key) || []
);
@@ -65,6 +72,9 @@ export default function RoleEdit() {
await adminApiService.updateRole(role.id, {
name,
description: description.trim() || undefined,
+ assignable,
+ defaultMember,
+ defaultCreator,
});
await adminApiService.updateRolePermissions(role.id, selectedPermissions);
navigate("/roles");
@@ -126,6 +136,29 @@ export default function RoleEdit() {
/>
+
+
+
+
+
diff --git a/packages/admin-ui/src/pages/Roles.tsx b/packages/admin-ui/src/pages/Roles.tsx
index b7215cb9..eb44d139 100644
--- a/packages/admin-ui/src/pages/Roles.tsx
+++ b/packages/admin-ui/src/pages/Roles.tsx
@@ -20,9 +20,35 @@ import {
TableRow,
} from "@/components/ui/table";
import tableStyles from "@/components/ui/table.module.css";
-import adminApiService, { type Role, type SortOrder } from "@/services/api";
+import adminApiService, { AdminApiRequestError, type Role, type SortOrder } from "@/services/api";
import { logger } from "@/services/logger";
+function hasRoleFlag(
+ role: Role,
+ camelKey: "defaultMember" | "defaultCreator",
+ snakeKey: "default_member" | "default_creator"
+) {
+ return role[camelKey] === true || role[snakeKey] === true;
+}
+
+const PROTECTED_ROLE_DELETE_CODES = new Set([
+ "ROLE_PROTECTED",
+ "ROLE_DEFAULT_PROTECTED",
+ "LAST_DEFAULT_MEMBER_ROLE",
+ "LAST_DEFAULT_CREATOR_ROLE",
+ "SYSTEM_ROLE_PROTECTED",
+]);
+
+function describeRoleDeleteError(error: unknown, role: Role): string {
+ if (error instanceof AdminApiRequestError) {
+ if (error.code && PROTECTED_ROLE_DELETE_CODES.has(error.code)) {
+ return `"${role.name}" is a protected role and cannot be deleted. ${error.message}`;
+ }
+ return error.message;
+ }
+ return error instanceof Error ? error.message : "Failed to delete role";
+}
+
export default function Roles() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
@@ -88,7 +114,7 @@ export default function Roles() {
setRoles((prev) => prev.filter((r) => r.id !== role.id));
setTotalCount((prev) => Math.max(prev - 1, 0));
} catch (deleteError) {
- setError(deleteError instanceof Error ? deleteError.message : "Failed to delete role");
+ setError(describeRoleDeleteError(deleteError, role));
}
};
@@ -168,6 +194,7 @@ export default function Roles() {
/>
Permissions
Type
+ Org Use
@@ -205,6 +232,22 @@ export default function Roles() {
{role.system ? "System" : "Custom"}
+
+
+ {role.assignable ? Assignable : null}
+ {hasRoleFlag(role, "defaultMember", "default_member") ? (
+ Default member
+ ) : null}
+ {hasRoleFlag(role, "defaultCreator", "default_creator") ? (
+ Default creator
+ ) : null}
+ {!role.assignable &&
+ !hasRoleFlag(role, "defaultMember", "default_member") &&
+ !hasRoleFlag(role, "defaultCreator", "default_creator") ? (
+ Admin only
+ ) : null}
+
+
([]);
const [creating, setCreating] = useState(false);
const [createdToken, setCreatedToken] = useState(null);
@@ -63,18 +72,35 @@ export default function ScimTokens() {
loadTokens();
}, [loadTokens]);
+ useEffect(() => {
+ let cancelled = false;
+ adminApiService
+ .getOrganizationsPaged({ page: 1, limit: 100, sortBy: "name", sortOrder: "asc" })
+ .then((response) => {
+ if (!cancelled) setOrganizations(response.organizations);
+ })
+ .catch(() => {
+ if (!cancelled) setOrganizations([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
const create = async () => {
- if (!name.trim()) return;
+ if (!name.trim() || !organizationId) return;
try {
setCreating(true);
setError(null);
const token = await adminApiService.createScimToken({
name: name.trim(),
+ organizationId,
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
});
setCreatedToken(token);
setName("");
setExpiresAt("");
+ setOrganizationId("");
setDialogOpen(false);
await loadTokens();
} catch (createError) {
@@ -181,6 +207,7 @@ export default function ScimTokens() {
Name
+ Organization
Prefix
Status
Created
@@ -193,6 +220,18 @@ export default function ScimTokens() {
{tokens.map((token) => (
{token.name}
+
+ {token.organizationName ? (
+
+
{token.organizationName}
+ {token.organizationSlug ? (
+
{token.organizationSlug}
+ ) : null}
+
+ ) : (
+ Unassigned
+ )}
+
{token.tokenPrefix}
@@ -238,6 +277,25 @@ export default function ScimTokens() {
+ Organization *}>
+
+
+
+
+
+ {organizations.map((organization) => (
+
+ {organization.slug
+ ? `${organization.name} (${organization.slug})`
+ : organization.name}
+
+ ))}
+
+
+
Name}>
setName(event.target.value)} />
@@ -250,7 +308,7 @@ export default function ScimTokens() {
-
+
Create
diff --git a/packages/admin-ui/src/pages/UserCreate.tsx b/packages/admin-ui/src/pages/UserCreate.tsx
index 4f6c4e23..26ff0db5 100644
--- a/packages/admin-ui/src/pages/UserCreate.tsx
+++ b/packages/admin-ui/src/pages/UserCreate.tsx
@@ -1,6 +1,8 @@
import { Copy, KeyRound } from "lucide-react";
-import { useId, useState } from "react";
+import { useEffect, useId, useState } from "react";
import { useNavigate } from "react-router-dom";
+import ErrorBanner from "@/components/feedback/error-banner";
+import CheckboxRow from "@/components/form/checkbox-row";
import FormActions from "@/components/layout/form-actions";
import { FormField, FormGrid } from "@/components/layout/form-grid";
import PageHeader from "@/components/layout/page-header";
@@ -16,7 +18,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import adminApiService from "@/services/api";
+import adminApiService, { type Organization } from "@/services/api";
import { sha256Base64Url } from "@/services/hash";
import adminOpaqueService from "@/services/opaque-cloudflare";
@@ -32,6 +34,28 @@ export default function UserCreate() {
const [submitting, setSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [generatedPassword, setGeneratedPassword] = useState("");
+ const [assignmentMode, setAssignmentMode] = useState<"existing" | "personal">("existing");
+ const [organizations, setOrganizations] = useState([]);
+ const [selectedOrganizationIds, setSelectedOrganizationIds] = useState([]);
+ const [loadingOrganizations, setLoadingOrganizations] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ adminApiService
+ .getOrganizationsPaged({ page: 1, limit: 100, sortBy: "name", sortOrder: "asc" })
+ .then((response) => {
+ if (!cancelled) setOrganizations(response.organizations);
+ })
+ .catch(() => {
+ if (!cancelled) setOrganizations([]);
+ })
+ .finally(() => {
+ if (!cancelled) setLoadingOrganizations(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
const generatePassword = (length = 16) => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*";
@@ -46,7 +70,14 @@ export default function UserCreate() {
try {
setSubmitting(true);
setError(null);
- const created = await adminApiService.createUser({ email, name, sub: sub || undefined });
+ const selectedOrganizations = assignmentMode === "existing" ? selectedOrganizationIds : [];
+ const created = await adminApiService.createUser({
+ email,
+ name,
+ sub: sub || undefined,
+ organizationIds: selectedOrganizations,
+ createPersonalOrganization: assignmentMode === "personal",
+ });
const pwd = generatePassword(20);
const start = await adminOpaqueService.startRegistration(pwd);
const startResp = await adminApiService.userPasswordSetStart(created.sub, start.request);
@@ -67,10 +98,20 @@ export default function UserCreate() {
}
};
+ const toggleOrganization = (organizationId: string, checked: boolean) => {
+ setSelectedOrganizationIds((current) => {
+ if (checked) {
+ if (current.includes(organizationId)) return current;
+ return [...current, organizationId];
+ }
+ return current.filter((id) => id !== organizationId);
+ });
+ };
+
return (
- {error &&
{error}
}
+ {error &&
{error} }
User Information
@@ -109,8 +150,66 @@ export default function UserCreate() {
/>
+
+
Organization assignment
+
+ setAssignmentMode("existing")}
+ disabled={submitting}
+ >
+ Assign existing
+
+ setAssignmentMode("personal")}
+ disabled={submitting}
+ >
+ Create personal
+
+
+ {assignmentMode === "existing" ? (
+
+ {loadingOrganizations ? (
+ Loading organizations...
+ ) : organizations.length === 0 ? (
+ No organizations are available.
+ ) : (
+ organizations.map((organization) => (
+
+ toggleOrganization(organization.organizationId, checked)
+ }
+ />
+ ))
+ )}
+
+ ) : (
+
+ The user creation request will ask the API to create a personal organization.
+
+ )}
+
-
+
Create
diff --git a/packages/admin-ui/src/services/api.ts b/packages/admin-ui/src/services/api.ts
index e7d5663d..cb624ba2 100644
--- a/packages/admin-ui/src/services/api.ts
+++ b/packages/admin-ui/src/services/api.ts
@@ -9,6 +9,20 @@ export interface ApiError {
export type SortOrder = "asc" | "desc";
+export class AdminApiRequestError extends Error {
+ code?: string;
+ status: number;
+ details?: unknown;
+
+ constructor(message: string, status: number, code?: string, details?: unknown) {
+ super(message);
+ this.name = "AdminApiRequestError";
+ this.status = status;
+ this.code = code;
+ this.details = details;
+ }
+}
+
export interface ListQueryParams {
page?: number;
limit?: number;
@@ -150,6 +164,11 @@ export interface Role {
name: string;
description?: string | null;
system?: boolean;
+ assignable?: boolean;
+ defaultMember?: boolean;
+ default_member?: boolean;
+ defaultCreator?: boolean;
+ default_creator?: boolean;
permissions?: Array<{ key: string; description?: string }>;
permissionKeys?: string[];
permissionCount?: number;
@@ -358,6 +377,9 @@ export interface FederationConnection {
id: string;
type: "oidc";
name: string;
+ organizationId?: string | null;
+ organizationName?: string | null;
+ organizationSlug?: string | null;
issuer: string;
clientId: string;
discoveryUrl: string;
@@ -394,6 +416,7 @@ export interface OidcDiscoveryMetadata {
export interface FederationConnectionRequest {
name: string;
+ organizationId?: string;
issuer: string;
clientId: string;
clientSecret?: string | null;
@@ -410,9 +433,25 @@ export interface FederationConnectionRequest {
enabled?: boolean;
}
+export type FederationDomainVerificationStatus = "pending" | "verified" | "failed" | string;
+
+export interface FederationConnectionDomain {
+ id: string;
+ domain: string;
+ verificationStatus: FederationDomainVerificationStatus;
+ recordName?: string | null;
+ recordValue?: string | null;
+ verifiedAt?: string | null;
+ lastCheckedAt?: string | null;
+ enabled?: boolean;
+}
+
export interface ScimBearerToken {
id: string;
name: string;
+ organizationId?: string | null;
+ organizationName?: string | null;
+ organizationSlug?: string | null;
tokenPrefix: string;
createdByAdminId?: string | null;
createdAt: string;
@@ -542,7 +581,12 @@ class AdminApiService {
}
}
- throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`);
+ const message =
+ (typeof data?.message === "string" && data.message) ||
+ (typeof data?.error === "string" && data.error) ||
+ `HTTP ${response.status}: ${response.statusText}`;
+ const code = typeof data?.code === "string" ? data.code : undefined;
+ throw new AdminApiRequestError(message, response.status, code, data?.details);
}
return data;
@@ -701,7 +745,13 @@ class AdminApiService {
return this.request(this.getPagedEndpoint("/users", params));
}
- async createUser(user: { email: string; name?: string; sub?: string }): Promise {
+ async createUser(user: {
+ email: string;
+ name?: string;
+ sub?: string;
+ organizationIds?: string[];
+ createPersonalOrganization?: boolean;
+ }): Promise {
return this.request("/users", {
method: "POST",
body: JSON.stringify(user),
@@ -957,6 +1007,9 @@ class AdminApiService {
name: string;
description?: string;
permissionKeys?: string[];
+ assignable?: boolean;
+ defaultMember?: boolean;
+ defaultCreator?: boolean;
}): Promise {
const response = await this.request("/roles", {
method: "POST",
@@ -967,7 +1020,13 @@ class AdminApiService {
async updateRole(
roleId: string,
- updates: { name?: string; description?: string }
+ updates: {
+ name?: string;
+ description?: string;
+ assignable?: boolean;
+ defaultMember?: boolean;
+ defaultCreator?: boolean;
+ }
): Promise {
const response = await this.request(`/roles/${roleId}`, {
method: "PUT",
@@ -1125,10 +1184,11 @@ class AdminApiService {
}
async getFederationConnections(
- params?: ListQueryParams & { enabled?: boolean }
+ params?: ListQueryParams & { enabled?: boolean; organizationId?: string }
): Promise {
const query = this.createListSearchParams(params);
if (typeof params?.enabled === "boolean") query.append("enabled", String(params.enabled));
+ if (params?.organizationId) query.append("organizationId", params.organizationId);
const endpoint = query.toString()
? `/federation/connections?${query.toString()}`
: "/federation/connections";
@@ -1162,6 +1222,46 @@ class AdminApiService {
await this.request(`/federation/connections/${encodeURIComponent(id)}`, { method: "DELETE" });
}
+ async getFederationConnectionDomains(
+ connectionId: string
+ ): Promise<{ domains: FederationConnectionDomain[] }> {
+ const data = await this.request<
+ { domains: FederationConnectionDomain[] } | FederationConnectionDomain[]
+ >(`/federation/connections/${encodeURIComponent(connectionId)}/domains`);
+ return Array.isArray(data) ? { domains: data } : data;
+ }
+
+ async addFederationConnectionDomain(
+ connectionId: string,
+ domain: string
+ ): Promise {
+ return this.request(`/federation/connections/${encodeURIComponent(connectionId)}/domains`, {
+ method: "POST",
+ body: JSON.stringify({ domain }),
+ });
+ }
+
+ async deleteFederationConnectionDomain(connectionId: string, domainId: string): Promise {
+ await this.request(
+ `/federation/connections/${encodeURIComponent(connectionId)}/domains/${encodeURIComponent(
+ domainId
+ )}`,
+ { method: "DELETE" }
+ );
+ }
+
+ async verifyFederationConnectionDomain(
+ connectionId: string,
+ domainId: string
+ ): Promise {
+ return this.request(
+ `/federation/connections/${encodeURIComponent(connectionId)}/domains/${encodeURIComponent(
+ domainId
+ )}/verify`,
+ { method: "POST" }
+ );
+ }
+
async discoverFederationOidc(issuer: string): Promise {
return this.request(`/federation/oidc/discovery?issuer=${encodeURIComponent(issuer)}`);
}
@@ -1178,6 +1278,7 @@ class AdminApiService {
async createScimToken(data: {
name: string;
+ organizationId?: string;
expiresAt?: string | null;
}): Promise {
return this.request("/scim/tokens", {
diff --git a/packages/api/drizzle/0034_role_flags_and_personal_orgs.sql b/packages/api/drizzle/0034_role_flags_and_personal_orgs.sql
new file mode 100644
index 00000000..df11273a
--- /dev/null
+++ b/packages/api/drizzle/0034_role_flags_and_personal_orgs.sql
@@ -0,0 +1,35 @@
+ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "assignable" boolean NOT NULL DEFAULT false;
+--> statement-breakpoint
+ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "default_member" boolean NOT NULL DEFAULT false;
+--> statement-breakpoint
+ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "default_creator" boolean NOT NULL DEFAULT false;
+--> statement-breakpoint
+UPDATE "roles"
+SET
+ "assignable" = true,
+ "default_creator" = true,
+ "updated_at" = now()
+WHERE "key" = 'org_admin';
+--> statement-breakpoint
+UPDATE "roles"
+SET
+ "assignable" = true,
+ "default_member" = true,
+ "updated_at" = now()
+WHERE "key" = 'member';
+--> statement-breakpoint
+WITH fallback_default_member AS (
+ SELECT "id" FROM "roles" ORDER BY "system" DESC, "created_at" ASC, "id" ASC LIMIT 1
+)
+UPDATE "roles"
+SET "default_member" = true, "updated_at" = now()
+WHERE "id" IN (SELECT "id" FROM fallback_default_member)
+ AND NOT EXISTS (SELECT 1 FROM "roles" WHERE "default_member" = true);
+--> statement-breakpoint
+WITH fallback_default_creator AS (
+ SELECT "id" FROM "roles" ORDER BY "system" DESC, "created_at" ASC, "id" ASC LIMIT 1
+)
+UPDATE "roles"
+SET "default_creator" = true, "updated_at" = now()
+WHERE "id" IN (SELECT "id" FROM fallback_default_creator)
+ AND NOT EXISTS (SELECT 1 FROM "roles" WHERE "default_creator" = true);
diff --git a/packages/api/drizzle/0035_federation_org_scope.sql b/packages/api/drizzle/0035_federation_org_scope.sql
new file mode 100644
index 00000000..efcae260
--- /dev/null
+++ b/packages/api/drizzle/0035_federation_org_scope.sql
@@ -0,0 +1,130 @@
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'federation_domain_verification_status') THEN
+ CREATE TYPE "federation_domain_verification_status" AS ENUM ('pending', 'verified', 'failed');
+ END IF;
+END $$;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "organization_id" uuid;
+--> statement-breakpoint
+UPDATE "federation_connections"
+SET "organization_id" = (
+ SELECT "id" FROM "organizations" ORDER BY "created_at" ASC, "id" ASC LIMIT 1
+)
+WHERE "organization_id" IS NULL;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ALTER COLUMN "organization_id" SET NOT NULL;
+--> statement-breakpoint
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'federation_connections_organization_id_organizations_id_fk'
+ ) THEN
+ ALTER TABLE "federation_connections"
+ ADD CONSTRAINT "federation_connections_organization_id_organizations_id_fk"
+ FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE;
+ END IF;
+END $$;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "protocol" text NOT NULL DEFAULT 'oidc';
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "jit_provisioning" boolean NOT NULL DEFAULT true;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "membership_on_authentication" boolean NOT NULL DEFAULT true;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "require_scim_pre_provisioning" boolean NOT NULL DEFAULT false;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "require_password_for_zk" boolean NOT NULL DEFAULT false;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "allow_passkey_prf" boolean NOT NULL DEFAULT true;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "allow_trusted_device_approval" boolean NOT NULL DEFAULT true;
+--> statement-breakpoint
+ALTER TABLE "federation_connections" ADD COLUMN IF NOT EXISTS "allow_non_zk_key_setup_bypass" boolean NOT NULL DEFAULT false;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "federation_connections_organization_id_idx" ON "federation_connections" ("organization_id");
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "federation_connection_domains" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ "connection_id" uuid NOT NULL,
+ "organization_id" uuid NOT NULL,
+ "domain" text NOT NULL,
+ "verification_status" "federation_domain_verification_status" NOT NULL DEFAULT 'pending',
+ "verification_token_hash" text,
+ "verified_at" timestamp,
+ "last_checked_at" timestamp,
+ "enabled" boolean NOT NULL DEFAULT true,
+ "created_at" timestamp NOT NULL DEFAULT now(),
+ "updated_at" timestamp NOT NULL DEFAULT now(),
+ CONSTRAINT "federation_connection_domains_connection_id_federation_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "federation_connections"("id") ON DELETE CASCADE,
+ CONSTRAINT "federation_connection_domains_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "federation_connection_domains_connection_domain_idx" ON "federation_connection_domains" ("connection_id", "domain");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "federation_connection_domains_connection_id_idx" ON "federation_connection_domains" ("connection_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "federation_connection_domains_organization_id_idx" ON "federation_connection_domains" ("organization_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "federation_connection_domains_domain_idx" ON "federation_connection_domains" ("domain");
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "federation_connection_domains_verified_unique_idx" ON "federation_connection_domains" ("domain") WHERE "enabled" = true AND "verification_status" = 'verified';
+--> statement-breakpoint
+INSERT INTO "federation_connection_domains" (
+ "connection_id",
+ "organization_id",
+ "domain",
+ "verification_status",
+ "verified_at",
+ "enabled",
+ "created_at",
+ "updated_at"
+)
+SELECT
+ fc."id",
+ fc."organization_id",
+ lower(trim(domain_value)),
+ 'verified'::federation_domain_verification_status,
+ now(),
+ true,
+ now(),
+ now()
+FROM "federation_connections" fc
+CROSS JOIN LATERAL unnest(fc."domains") AS domain_value
+WHERE trim(domain_value) <> ''
+ON CONFLICT DO NOTHING;
+--> statement-breakpoint
+ALTER TABLE "federation_oidc_states" ADD COLUMN IF NOT EXISTS "organization_id" uuid;
+--> statement-breakpoint
+UPDATE "federation_oidc_states" fos
+SET "organization_id" = fc."organization_id"
+FROM "federation_connections" fc
+WHERE fos."connection_id" = fc."id" AND fos."organization_id" IS NULL;
+--> statement-breakpoint
+ALTER TABLE "federation_oidc_states" ALTER COLUMN "organization_id" SET NOT NULL;
+--> statement-breakpoint
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'federation_oidc_states_organization_id_organizations_id_fk'
+ ) THEN
+ ALTER TABLE "federation_oidc_states"
+ ADD CONSTRAINT "federation_oidc_states_organization_id_organizations_id_fk"
+ FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE;
+ END IF;
+END $$;
+--> statement-breakpoint
+ALTER TABLE "federation_oidc_states" ADD COLUMN IF NOT EXISTS "client_id" text DEFAULT 'user';
+--> statement-breakpoint
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'federation_oidc_states_client_id_clients_client_id_fk'
+ ) THEN
+ ALTER TABLE "federation_oidc_states"
+ ADD CONSTRAINT "federation_oidc_states_client_id_clients_client_id_fk"
+ FOREIGN KEY ("client_id") REFERENCES "clients"("client_id") ON DELETE SET NULL;
+ END IF;
+END $$;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "federation_oidc_states_organization_id_idx" ON "federation_oidc_states" ("organization_id");
diff --git a/packages/api/drizzle/0036_scim_organization_scope.sql b/packages/api/drizzle/0036_scim_organization_scope.sql
new file mode 100644
index 00000000..f5728cba
--- /dev/null
+++ b/packages/api/drizzle/0036_scim_organization_scope.sql
@@ -0,0 +1,108 @@
+CREATE TABLE "scim_connections" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "organization_id" uuid NOT NULL,
+ "name" text NOT NULL,
+ "enabled" boolean DEFAULT true NOT NULL,
+ "deprovision_action" text DEFAULT 'suspend_membership' NOT NULL,
+ "delete_user_safety" text DEFAULT 'fail_closed' NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "scim_connections" ADD CONSTRAINT "scim_connections_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "scim_bearer_tokens" ADD COLUMN "connection_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "scim_bearer_tokens" ADD COLUMN "organization_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "scim_bearer_tokens" ADD COLUMN "scopes" text[] DEFAULT ARRAY['scim:read','scim:write']::text[] NOT NULL;
+--> statement-breakpoint
+ALTER TABLE "scim_bearer_tokens" ADD CONSTRAINT "scim_bearer_tokens_connection_id_scim_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connections"("id") ON DELETE cascade ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "scim_bearer_tokens" ADD CONSTRAINT "scim_bearer_tokens_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "scim_users" ADD COLUMN "connection_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "scim_users" ADD COLUMN "organization_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "scim_users" ADD COLUMN "organization_member_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "scim_users" ADD COLUMN "id" uuid DEFAULT gen_random_uuid() NOT NULL;
+--> statement-breakpoint
+ALTER TABLE "scim_users" DROP CONSTRAINT "scim_users_pkey";
+--> statement-breakpoint
+ALTER TABLE "scim_users" ADD CONSTRAINT "scim_users_pkey" PRIMARY KEY ("id");
+--> statement-breakpoint
+ALTER TABLE "scim_users" ADD CONSTRAINT "scim_users_connection_id_scim_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connections"("id") ON DELETE cascade ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "scim_users" ADD CONSTRAINT "scim_users_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "scim_users" ADD CONSTRAINT "scim_users_organization_member_id_organization_members_id_fk" FOREIGN KEY ("organization_member_id") REFERENCES "public"."organization_members"("id") ON DELETE set null ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "scim_groups" ADD COLUMN "connection_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "scim_groups" ADD COLUMN "organization_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "scim_groups" ADD CONSTRAINT "scim_groups_connection_id_scim_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connections"("id") ON DELETE cascade ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "scim_groups" ADD CONSTRAINT "scim_groups_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "organization_members" ADD COLUMN "scim_connection_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_scim_connection_id_scim_connections_id_fk" FOREIGN KEY ("scim_connection_id") REFERENCES "public"."scim_connections"("id") ON DELETE set null ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "organization_member_roles" ADD COLUMN "scim_connection_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "organization_member_roles" ADD COLUMN "scim_group_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "organization_member_roles" ADD CONSTRAINT "organization_member_roles_scim_connection_id_scim_connections_id_fk" FOREIGN KEY ("scim_connection_id") REFERENCES "public"."scim_connections"("id") ON DELETE set null ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "organization_member_roles" ADD CONSTRAINT "organization_member_roles_scim_group_id_scim_groups_id_fk" FOREIGN KEY ("scim_group_id") REFERENCES "public"."scim_groups"("id") ON DELETE set null ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "audit_logs" ADD COLUMN "organization_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE set null ON UPDATE no action;
+--> statement-breakpoint
+DROP INDEX IF EXISTS "scim_users_external_id_idx";
+--> statement-breakpoint
+DROP INDEX IF EXISTS "scim_users_user_name_idx";
+--> statement-breakpoint
+DROP INDEX IF EXISTS "scim_groups_external_id_idx";
+--> statement-breakpoint
+DROP INDEX IF EXISTS "scim_groups_display_name_idx";
+--> statement-breakpoint
+CREATE INDEX "scim_connections_organization_id_idx" ON "scim_connections" USING btree ("organization_id");
+--> statement-breakpoint
+CREATE INDEX "scim_connections_enabled_idx" ON "scim_connections" USING btree ("enabled");
+--> statement-breakpoint
+CREATE INDEX "scim_bearer_tokens_connection_id_idx" ON "scim_bearer_tokens" USING btree ("connection_id");
+--> statement-breakpoint
+CREATE INDEX "scim_bearer_tokens_organization_id_idx" ON "scim_bearer_tokens" USING btree ("organization_id");
+--> statement-breakpoint
+CREATE UNIQUE INDEX "scim_users_connection_external_id_idx" ON "scim_users" USING btree ("connection_id","external_id");
+--> statement-breakpoint
+CREATE UNIQUE INDEX "scim_users_connection_user_name_idx" ON "scim_users" USING btree ("connection_id","user_name");
+--> statement-breakpoint
+CREATE UNIQUE INDEX "scim_users_connection_user_sub_idx" ON "scim_users" USING btree ("connection_id","user_sub");
+--> statement-breakpoint
+CREATE INDEX "scim_users_connection_id_idx" ON "scim_users" USING btree ("connection_id");
+--> statement-breakpoint
+CREATE INDEX "scim_users_organization_id_idx" ON "scim_users" USING btree ("organization_id");
+--> statement-breakpoint
+CREATE INDEX "scim_users_user_sub_idx" ON "scim_users" USING btree ("user_sub");
+--> statement-breakpoint
+CREATE UNIQUE INDEX "scim_groups_connection_external_id_idx" ON "scim_groups" USING btree ("connection_id","external_id");
+--> statement-breakpoint
+CREATE UNIQUE INDEX "scim_groups_connection_display_name_idx" ON "scim_groups" USING btree ("connection_id","display_name");
+--> statement-breakpoint
+CREATE INDEX "scim_groups_connection_id_idx" ON "scim_groups" USING btree ("connection_id");
+--> statement-breakpoint
+CREATE INDEX "scim_groups_organization_id_idx" ON "scim_groups" USING btree ("organization_id");
+--> statement-breakpoint
+CREATE INDEX "organization_members_scim_connection_id_idx" ON "organization_members" USING btree ("scim_connection_id");
+--> statement-breakpoint
+CREATE INDEX "organization_member_roles_scim_connection_id_idx" ON "organization_member_roles" USING btree ("scim_connection_id");
+--> statement-breakpoint
+CREATE INDEX "organization_member_roles_scim_group_id_idx" ON "organization_member_roles" USING btree ("scim_group_id");
+--> statement-breakpoint
+CREATE INDEX "audit_logs_organization_id_idx" ON "audit_logs" USING btree ("organization_id");
diff --git a/packages/api/drizzle/0037_audit_enterprise_connection.sql b/packages/api/drizzle/0037_audit_enterprise_connection.sql
new file mode 100644
index 00000000..b83676eb
--- /dev/null
+++ b/packages/api/drizzle/0037_audit_enterprise_connection.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "audit_logs" ADD COLUMN IF NOT EXISTS "enterprise_connection_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "audit_logs" ADD COLUMN IF NOT EXISTS "enterprise_connection_type" text;
diff --git a/packages/api/drizzle/0038_federation_email_linking_migration.sql b/packages/api/drizzle/0038_federation_email_linking_migration.sql
new file mode 100644
index 00000000..3bec4ca4
--- /dev/null
+++ b/packages/api/drizzle/0038_federation_email_linking_migration.sql
@@ -0,0 +1,10 @@
+-- Decision: the legacy account_linking_policy value 'email' (link/create on any
+-- matching email, even when unverified) is unsafe and is already rejected by the
+-- model/controller validation. Rather than drop the enum value (which would be a
+-- breaking, non-idempotent enum mutation), we normalise any existing rows that
+-- still carry 'email' to the nearest safe behaviour, 'email_verified'. This keeps
+-- runtime behaviour consistent with validation while preserving stored data.
+-- Idempotent: re-running only affects rows that are still 'email'.
+UPDATE "federation_connections"
+SET "account_linking_policy" = 'email_verified'
+WHERE "account_linking_policy" = 'email';
diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json
index 67061e21..02e6454a 100644
--- a/packages/api/drizzle/meta/_journal.json
+++ b/packages/api/drizzle/meta/_journal.json
@@ -225,6 +225,41 @@
"when": 1780261200000,
"tag": "0033_user_opaque_login_identity",
"breakpoints": true
+ },
+ {
+ "idx": 32,
+ "version": "7",
+ "when": 1780347600000,
+ "tag": "0034_role_flags_and_personal_orgs",
+ "breakpoints": true
+ },
+ {
+ "idx": 33,
+ "version": "7",
+ "when": 1780434000000,
+ "tag": "0035_federation_org_scope",
+ "breakpoints": true
+ },
+ {
+ "idx": 34,
+ "version": "7",
+ "when": 1780520400000,
+ "tag": "0036_scim_organization_scope",
+ "breakpoints": true
+ },
+ {
+ "idx": 35,
+ "version": "7",
+ "when": 1780606800000,
+ "tag": "0037_audit_enterprise_connection",
+ "breakpoints": true
+ },
+ {
+ "idx": 36,
+ "version": "7",
+ "when": 1780693200000,
+ "tag": "0038_federation_email_linking_migration",
+ "breakpoints": true
}
]
}
diff --git a/packages/api/src/context/createContext.ts b/packages/api/src/context/createContext.ts
index b999756c..2a1dca6e 100644
--- a/packages/api/src/context/createContext.ts
+++ b/packages/api/src/context/createContext.ts
@@ -5,7 +5,7 @@ import pino from "pino";
import { createPglite } from "../db/pglite.ts";
import * as schema from "../db/schema.ts";
import { opaqueLoginSessions } from "../db/schema.ts";
-import { ensureDefaultOrganizationAndSchema } from "../models/install.ts";
+import { ensureOrganizationSchema } from "../models/install.ts";
import { ensureKekService } from "../services/kek.ts";
import { createOpaqueService } from "../services/opaque.ts";
import { cleanupExpiredSessions } from "../services/sessions.ts";
@@ -181,9 +181,9 @@ export async function createContext(config: Config): Promise {
if (!config.inInstallMode) {
try {
- await ensureDefaultOrganizationAndSchema(context);
+ await ensureOrganizationSchema(context);
} catch (err) {
- logger.warn({ err }, "ensureDefaultOrganizationAndSchema failed");
+ logger.warn({ err }, "ensureOrganizationSchema failed");
}
try {
await pruneDeprecatedSettings(context);
diff --git a/packages/api/src/controllers/admin/federationConnections.ts b/packages/api/src/controllers/admin/federationConnections.ts
index 71b09fd5..9966b994 100644
--- a/packages/api/src/controllers/admin/federationConnections.ts
+++ b/packages/api/src/controllers/admin/federationConnections.ts
@@ -4,11 +4,15 @@ import { ForbiddenError, ValidationError } from "../../errors.ts";
import { genericErrors } from "../../http/openapi-helpers.ts";
import {
createFederationConnection,
+ createFederationConnectionDomain,
deleteFederationConnection,
+ deleteFederationConnectionDomain,
discoverOidcMetadata,
findFederationConnectionForEmail,
getFederationConnection,
+ listFederationConnectionDomains,
listFederationConnections,
+ runFederationDomainDnsVerification,
updateFederationConnection,
} from "../../models/federation.ts";
import { requireSession } from "../../services/sessions.ts";
@@ -47,6 +51,7 @@ const OidcMetadataSchema = z
.passthrough();
const ConnectionRequestSchema = z.object({
+ organizationId: z.string().uuid().optional(),
name: z.string().min(1).max(255),
issuer: z.string().url(),
clientId: z.string().min(1).max(255),
@@ -60,6 +65,13 @@ const ConnectionRequestSchema = z.object({
scopes: z.array(z.string().min(1)).optional(),
claimMapping: ClaimMappingSchema,
accountLinkingPolicy: z.enum(["disabled", "email_verified"]).optional(),
+ jitProvisioning: z.boolean().optional(),
+ membershipOnAuthentication: z.boolean().optional(),
+ requireScimPreProvisioning: z.boolean().optional(),
+ requirePasswordForZk: z.boolean().optional(),
+ allowPasskeyPrf: z.boolean().optional(),
+ allowTrustedDeviceApproval: z.boolean().optional(),
+ allowNonZkKeySetupBypass: z.boolean().optional(),
domains: z.array(z.string().min(1)).optional(),
enabled: z.boolean().optional(),
});
@@ -69,6 +81,8 @@ const ConnectionUpdateSchema = ConnectionRequestSchema.partial();
const ConnectionResponseSchema = z.object({
id: z.string().uuid(),
type: z.literal("oidc"),
+ protocol: z.string(),
+ organizationId: z.string().uuid(),
name: z.string(),
issuer: z.string(),
clientId: z.string(),
@@ -80,6 +94,13 @@ const ConnectionResponseSchema = z.object({
scopes: z.array(z.string()),
claimMapping: z.record(z.string(), z.unknown()),
accountLinkingPolicy: z.enum(["disabled", "email_verified", "email"]),
+ jitProvisioning: z.boolean(),
+ membershipOnAuthentication: z.boolean(),
+ requireScimPreProvisioning: z.boolean(),
+ requirePasswordForZk: z.boolean(),
+ allowPasskeyPrf: z.boolean(),
+ allowTrustedDeviceApproval: z.boolean(),
+ allowNonZkKeySetupBypass: z.boolean(),
domains: z.array(z.string()),
enabled: z.boolean(),
metadata: z.record(z.string(), z.unknown()),
@@ -110,6 +131,23 @@ async function requireAdmin(context: Context, request: IncomingMessage, write =
if (write && session.adminRole !== "write") throw new ForbiddenError("Write access required");
}
+function federationAuditContext(responseData: unknown, fallbackConnectionId?: string) {
+ const record =
+ responseData && typeof responseData === "object"
+ ? (responseData as { id?: unknown; organizationId?: unknown })
+ : undefined;
+ const connectionId =
+ typeof record?.id === "string" ? record.id : fallbackConnectionId || undefined;
+ const organizationId =
+ typeof record?.organizationId === "string" ? record.organizationId : undefined;
+ if (!connectionId && !organizationId) return undefined;
+ return {
+ organizationId,
+ enterpriseConnectionId: connectionId,
+ enterpriseConnectionType: "federation",
+ };
+}
+
export async function getFederationConnections(
context: Context,
request: IncomingMessage,
@@ -151,6 +189,7 @@ export const postFederationConnection = withAudit({
resourceType: "federation_connection",
extractResourceId: (body: unknown) =>
body && typeof body === "object" ? (body as { issuer?: string }).issuer : undefined,
+ extractAuditContext: (_body, responseData) => federationAuditContext(responseData),
})(postFederationConnectionHandler);
export async function getFederationConnectionController(
@@ -182,6 +221,8 @@ export const putFederationConnection = withAudit({
eventType: "FEDERATION_CONNECTION_UPDATE",
resourceType: "federation_connection",
extractResourceId: (_body: unknown, params: string[]) => params[0],
+ extractAuditContext: (_body, responseData, params) =>
+ federationAuditContext(responseData, params[0]),
})(putFederationConnectionHandler);
async function deleteFederationConnectionHandler(
@@ -199,9 +240,165 @@ export const deleteFederationConnectionController = withAudit({
eventType: "FEDERATION_CONNECTION_DELETE",
resourceType: "federation_connection",
extractResourceId: (_body: unknown, params: string[]) => params[0],
+ extractAuditContext: (_body, responseData, params) =>
+ federationAuditContext(responseData, params[0]),
skipBodyCapture: true,
})(deleteFederationConnectionHandler);
+const DomainResponseSchema = z.object({
+ id: z.string().uuid(),
+ domain: z.string(),
+ verificationStatus: z.enum(["pending", "verified", "failed"]),
+ recordName: z.string(),
+ recordValue: z.string().nullable(),
+});
+
+const DomainListResponseSchema = z.object({
+ domains: z.array(
+ z.object({
+ id: z.string().uuid(),
+ domain: z.string(),
+ verificationStatus: z.enum(["pending", "verified", "failed"]),
+ verifiedAt: z.date().or(z.string()).nullable(),
+ lastCheckedAt: z.date().or(z.string()).nullable(),
+ enabled: z.boolean(),
+ recordName: z.string(),
+ })
+ ),
+});
+
+const DomainVerifyResponseSchema = z.object({
+ id: z.string().uuid(),
+ domain: z.string(),
+ verificationStatus: z.enum(["pending", "verified", "failed"]),
+ lastCheckedAt: z.date().or(z.string()).nullable(),
+});
+
+export async function getFederationConnectionDomains(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ connectionId: string
+) {
+ await requireAdmin(context, request);
+ await getFederationConnection(context, connectionId);
+ const domains = await listFederationConnectionDomains(context, connectionId);
+ sendJsonValidated(
+ response,
+ 200,
+ {
+ domains: domains.map((domain) => ({
+ id: domain.id,
+ domain: domain.domain,
+ verificationStatus: domain.verificationStatus,
+ verifiedAt: domain.verifiedAt,
+ lastCheckedAt: domain.lastCheckedAt,
+ enabled: domain.enabled,
+ recordName: domain.recordName,
+ })),
+ },
+ DomainListResponseSchema
+ );
+}
+
+async function postFederationConnectionDomainHandler(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ connectionId: string
+) {
+ await requireAdmin(context, request, true);
+ const raw = parseJsonSafely(await readBody(request));
+ const parsed = z.object({ domain: z.string().min(1) }).safeParse(raw);
+ if (!parsed.success) throw new ValidationError("Validation error", parsed.error.issues);
+ const created = await createFederationConnectionDomain(context, {
+ connectionId,
+ domain: parsed.data.domain,
+ });
+ sendJsonValidated(
+ response,
+ 201,
+ {
+ id: created.id,
+ domain: created.domain,
+ verificationStatus: created.verificationStatus,
+ recordName: created.recordName,
+ recordValue: created.recordValue,
+ },
+ DomainResponseSchema
+ );
+}
+
+export const postFederationConnectionDomain = withAudit({
+ eventType: "FEDERATION_DOMAIN_CREATE",
+ resourceType: "federation_connection_domain",
+ extractResourceId: (_body: unknown, params: string[]) => params[0],
+ extractAuditContext: (_body, responseData, params) => ({
+ enterpriseConnectionId: params[0],
+ enterpriseConnectionType: "federation",
+ organizationId:
+ responseData && typeof responseData === "object"
+ ? (responseData as { organizationId?: string }).organizationId
+ : undefined,
+ }),
+})(postFederationConnectionDomainHandler);
+
+async function deleteFederationConnectionDomainHandler(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ connectionId: string,
+ domainId: string
+) {
+ await requireAdmin(context, request, true);
+ const result = await deleteFederationConnectionDomain(context, connectionId, domainId);
+ sendJson(response, 200, result);
+}
+
+export const deleteFederationConnectionDomainController = withAudit({
+ eventType: "FEDERATION_DOMAIN_DELETE",
+ resourceType: "federation_connection_domain",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) => ({
+ enterpriseConnectionId: params[0],
+ enterpriseConnectionType: "federation",
+ }),
+ skipBodyCapture: true,
+})(deleteFederationConnectionDomainHandler);
+
+async function verifyFederationConnectionDomainHandler(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ connectionId: string,
+ domainId: string
+) {
+ await requireAdmin(context, request, true);
+ const result = await runFederationDomainDnsVerification(context, connectionId, domainId);
+ sendJsonValidated(
+ response,
+ 200,
+ {
+ id: result.domain.id,
+ domain: result.domain.domain,
+ verificationStatus: result.domain.verificationStatus,
+ lastCheckedAt: result.domain.lastCheckedAt,
+ },
+ DomainVerifyResponseSchema
+ );
+}
+
+export const verifyFederationConnectionDomainController = withAudit({
+ eventType: "FEDERATION_DOMAIN_VERIFY",
+ resourceType: "federation_connection_domain",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) => ({
+ enterpriseConnectionId: params[0],
+ enterpriseConnectionType: "federation",
+ }),
+ skipBodyCapture: true,
+})(verifyFederationConnectionDomainHandler);
+
export async function getFederationDomainRoute(
context: Context,
request: IncomingMessage,
@@ -211,7 +408,8 @@ export async function getFederationDomainRoute(
const url = new URL(request.url || "", `http://${request.headers.host}`);
const email = url.searchParams.get("email");
if (!email) throw new ValidationError("email is required");
- const connection = await findFederationConnectionForEmail(context, email);
+ const organizationId = url.searchParams.get("organization_id") || undefined;
+ const connection = await findFederationConnectionForEmail(context, email, { organizationId });
sendJsonValidated(response, 200, { connection }, DomainRouteResponseSchema);
}
diff --git a/packages/api/src/controllers/admin/roleCreate.ts b/packages/api/src/controllers/admin/roleCreate.ts
index f0681eb0..32ef39d0 100644
--- a/packages/api/src/controllers/admin/roleCreate.ts
+++ b/packages/api/src/controllers/admin/roleCreate.ts
@@ -13,6 +13,9 @@ const RequestSchema = z.object({
name: z.string().min(1),
description: z.string().nullable().optional(),
permissionKeys: z.array(z.string()).optional(),
+ assignable: z.boolean().optional(),
+ defaultMember: z.boolean().optional(),
+ defaultCreator: z.boolean().optional(),
});
const ResponseSchema = z.object({
@@ -22,6 +25,9 @@ const ResponseSchema = z.object({
name: z.string(),
description: z.string().nullable().optional(),
system: z.boolean(),
+ assignable: z.boolean(),
+ defaultMember: z.boolean(),
+ defaultCreator: z.boolean(),
permissionKeys: z.array(z.string()),
}),
});
diff --git a/packages/api/src/controllers/admin/roleGet.ts b/packages/api/src/controllers/admin/roleGet.ts
index 554a72c2..f88332bc 100644
--- a/packages/api/src/controllers/admin/roleGet.ts
+++ b/packages/api/src/controllers/admin/roleGet.ts
@@ -14,6 +14,9 @@ const ResponseSchema = z.object({
name: z.string(),
description: z.string().nullable().optional(),
system: z.boolean(),
+ assignable: z.boolean(),
+ defaultMember: z.boolean(),
+ defaultCreator: z.boolean(),
permissionKeys: z.array(z.string()),
}),
});
diff --git a/packages/api/src/controllers/admin/roleUpdate.ts b/packages/api/src/controllers/admin/roleUpdate.ts
index 1676025a..6de4b2b8 100644
--- a/packages/api/src/controllers/admin/roleUpdate.ts
+++ b/packages/api/src/controllers/admin/roleUpdate.ts
@@ -9,10 +9,24 @@ import { withAudit } from "../../utils/auditWrapper.ts";
import { parseJsonSafely, readBody, sendJsonValidated } from "../../utils/http.ts";
const RequestSchema = z
- .object({ name: z.string().min(1).optional(), description: z.string().nullable().optional() })
- .refine((data) => data.name !== undefined || Object.hasOwn(data, "description"), {
- message: "Provide at least one field",
- });
+ .object({
+ name: z.string().min(1).optional(),
+ description: z.string().nullable().optional(),
+ assignable: z.boolean().optional(),
+ defaultMember: z.boolean().optional(),
+ defaultCreator: z.boolean().optional(),
+ })
+ .refine(
+ (data) =>
+ data.name !== undefined ||
+ Object.hasOwn(data, "description") ||
+ data.assignable !== undefined ||
+ data.defaultMember !== undefined ||
+ data.defaultCreator !== undefined,
+ {
+ message: "Provide at least one field",
+ }
+ );
const ResponseSchema = z.object({
role: z.object({
@@ -21,6 +35,9 @@ const ResponseSchema = z.object({
name: z.string(),
description: z.string().nullable().optional(),
system: z.boolean(),
+ assignable: z.boolean(),
+ defaultMember: z.boolean(),
+ defaultCreator: z.boolean(),
}),
});
diff --git a/packages/api/src/controllers/admin/roles.ts b/packages/api/src/controllers/admin/roles.ts
index 37c9941f..cf34209a 100644
--- a/packages/api/src/controllers/admin/roles.ts
+++ b/packages/api/src/controllers/admin/roles.ts
@@ -18,6 +18,9 @@ const RoleSchema = z.object({
name: z.string(),
description: z.string().nullable().optional(),
system: z.boolean(),
+ assignable: z.boolean(),
+ defaultMember: z.boolean(),
+ defaultCreator: z.boolean(),
permissionKeys: z.array(z.string()),
});
diff --git a/packages/api/src/controllers/admin/scimTokens.ts b/packages/api/src/controllers/admin/scimTokens.ts
index bcb83c12..eecf6c83 100644
--- a/packages/api/src/controllers/admin/scimTokens.ts
+++ b/packages/api/src/controllers/admin/scimTokens.ts
@@ -32,17 +32,25 @@ export async function postScimToken(
const parsed = z
.object({
name: z.string().min(1),
+ organizationId: z.string().uuid(),
+ connectionId: z.string().uuid().nullable().optional(),
+ connectionName: z.string().min(1).nullable().optional(),
expiresAt: z.string().datetime().nullable().optional(),
})
.parse(body);
const token = await createScimBearerToken(context, {
name: parsed.name,
+ organizationId: parsed.organizationId,
+ connectionId: parsed.connectionId,
+ connectionName: parsed.connectionName,
createdByAdminId: session.adminId,
expiresAt: parsed.expiresAt ? new Date(parsed.expiresAt) : null,
});
await auditScimTokenEvent(context, request, {
eventType: "SCIM_TOKEN_CREATE",
adminId: session.adminId,
+ organizationId: token.organizationId || undefined,
+ connectionId: token.connectionId || undefined,
resourceId: token.id,
statusCode: 201,
details: {
@@ -66,6 +74,7 @@ export async function deleteScimToken(
await auditScimTokenEvent(context, request, {
eventType: "SCIM_TOKEN_REVOKE",
adminId: session.adminId,
+ organizationId: undefined,
resourceId: tokenId,
statusCode: 200,
});
@@ -78,6 +87,8 @@ async function auditScimTokenEvent(
data: {
eventType: string;
adminId?: string;
+ organizationId?: string;
+ connectionId?: string;
resourceId?: string;
statusCode: number;
details?: Record;
@@ -90,6 +101,9 @@ async function auditScimTokenEvent(
path: request.url || "/",
cohort: "admin",
adminId: data.adminId,
+ organizationId: data.organizationId,
+ enterpriseConnectionId: data.connectionId,
+ enterpriseConnectionType: data.connectionId ? "scim" : undefined,
ipAddress: getClientIp(request),
userAgent: Array.isArray(userAgent) ? userAgent[0] : userAgent,
success: true,
diff --git a/packages/api/src/controllers/admin/userCreate.ts b/packages/api/src/controllers/admin/userCreate.ts
index c4231a36..9c7d16d6 100644
--- a/packages/api/src/controllers/admin/userCreate.ts
+++ b/packages/api/src/controllers/admin/userCreate.ts
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { z } from "zod/v4";
-import { ForbiddenError } from "../../errors.ts";
+import { ForbiddenError, ValidationError } from "../../errors.ts";
import { genericErrors } from "../../http/openapi-helpers.ts";
import { createUser as createUserModel } from "../../models/users.ts";
import type { Context, ControllerSchema } from "../../types.ts";
@@ -27,12 +27,26 @@ async function createUserHandler(
email: z.string().email(),
name: z.string().optional(),
sub: z.string().optional(),
+ organizationIds: z.array(z.string().uuid()).optional(),
+ createPersonalOrganization: z.boolean().optional(),
+ personalOrganizationName: z.string().optional(),
+ personalOrganizationSlug: z.string().optional(),
});
const parsed = Req.parse(raw);
+ const organizationIds = parsed.organizationIds || [];
+ if (organizationIds.length === 0 && parsed.createPersonalOrganization !== true) {
+ throw new ValidationError(
+ "Organization assignment or personal organization creation is required"
+ );
+ }
const result = await createUserModel(context, {
email: parsed.email.trim(),
name: parsed.name?.trim() || "",
sub: parsed.sub?.trim(),
+ organizationIds,
+ createPersonalOrganization: parsed.createPersonalOrganization,
+ personalOrganizationName: parsed.personalOrganizationName?.trim(),
+ personalOrganizationSlug: parsed.personalOrganizationSlug?.trim(),
});
sendJson(response, 201, result);
}
@@ -52,6 +66,10 @@ const Req = z.object({
email: z.string().email(),
name: z.string().optional(),
sub: z.string().optional(),
+ organizationIds: z.array(z.string().uuid()).optional(),
+ createPersonalOrganization: z.boolean().optional(),
+ personalOrganizationName: z.string().optional(),
+ personalOrganizationSlug: z.string().optional(),
});
const Resp = z.object({
diff --git a/packages/api/src/controllers/install/postInstallComplete.ts b/packages/api/src/controllers/install/postInstallComplete.ts
index 5a4e3a66..af7de17d 100644
--- a/packages/api/src/controllers/install/postInstallComplete.ts
+++ b/packages/api/src/controllers/install/postInstallComplete.ts
@@ -178,7 +178,7 @@ async function _postInstallComplete(
updatedAt: new Date(),
})
.where(eq(clients.clientId, "user"));
- await (await import("../../models/install.ts")).ensureDefaultOrganizationAndSchema(installCtx);
+ await (await import("../../models/install.ts")).ensureOrganizationSchema(installCtx);
context.logger.debug(
"[install:post] verifying admin user was created during OPAQUE registration"
diff --git a/packages/api/src/controllers/scim.test.ts b/packages/api/src/controllers/scim.test.ts
index 2c5aea3b..fd8d3fae 100644
--- a/packages/api/src/controllers/scim.test.ts
+++ b/packages/api/src/controllers/scim.test.ts
@@ -7,7 +7,7 @@ import { Readable } from "node:stream";
import { test } from "node:test";
import { eq } from "drizzle-orm";
import { createPglite } from "../db/pglite.ts";
-import { auditLogs } from "../db/schema.ts";
+import { auditLogs, organizations } from "../db/schema.ts";
import { createScimBearerToken } from "../models/scim.ts";
import type { Context } from "../types.ts";
import { handleScim } from "./scim.ts";
@@ -81,10 +81,24 @@ function createResponse(): ServerResponse & { body: string; json: unknown } {
return response as ServerResponse & { body: string; json: unknown };
}
+async function createOrganization(context: Context) {
+ const organization = {
+ id: "11111111-1111-4111-8111-111111111111",
+ slug: "engineering",
+ name: "Engineering",
+ };
+ await context.db.insert(organizations).values(organization);
+ return organization;
+}
+
test("SCIM resource mutations write safe audit events", async () => {
const { context, cleanup } = await createContext();
try {
- const token = await createScimBearerToken(context, { name: "Directory" });
+ const organization = await createOrganization(context);
+ const token = await createScimBearerToken(context, {
+ name: "Directory",
+ organizationId: organization.id,
+ });
const createResponseBody = createResponse();
await handleScim(
context,
@@ -125,6 +139,7 @@ test("SCIM resource mutations write safe audit events", async () => {
assert.equal(createAudit?.resourceType, "scim_user");
assert.equal(createAudit?.resourceId, userId);
+ assert.equal(createAudit?.organizationId, organization.id);
assert.equal(createAudit?.success, true);
assert.deepEqual(createAudit?.details, {
token_id: token.id,
diff --git a/packages/api/src/controllers/scim.ts b/packages/api/src/controllers/scim.ts
index 2db60b0f..eea8b116 100644
--- a/packages/api/src/controllers/scim.ts
+++ b/packages/api/src/controllers/scim.ts
@@ -56,6 +56,8 @@ async function auditScimEvent(
resourceType: string;
resourceId?: string;
tokenId?: string;
+ organizationId?: string;
+ connectionId?: string;
statusCode?: number;
details?: Record;
}
@@ -70,6 +72,9 @@ async function auditScimEvent(
userAgent: Array.isArray(userAgent) ? userAgent[0] : userAgent,
success: true,
statusCode: data.statusCode ?? 200,
+ organizationId: data.organizationId,
+ enterpriseConnectionId: data.connectionId,
+ enterpriseConnectionType: data.connectionId ? "scim" : undefined,
resourceType: data.resourceType,
resourceId: data.resourceId,
action: (request.method || "UNKNOWN").toLowerCase(),
@@ -176,15 +181,17 @@ export async function handleScim(
if (pathname === "/scim/v2/Users") {
if (method === "GET")
- return sendJson(response, 200, await listScimUsers(context, query(request)));
+ return sendJson(response, 200, await listScimUsers(context, bearer, query(request)));
if (method === "POST") {
const body = parseJsonSafely(await readBody(request));
- const resource = await createScimUser(context, body as Record);
+ const resource = await createScimUser(context, bearer, body as Record);
await auditScimEvent(context, request, {
eventType: "SCIM_USER_CREATE",
resourceType: "scim_user",
resourceId: resource.id,
- tokenId: bearer.id,
+ tokenId: bearer.tokenId,
+ organizationId: bearer.organizationId,
+ connectionId: bearer.id,
statusCode: 201,
details: scimUserAuditDetails("create", resource),
});
@@ -195,38 +202,50 @@ export async function handleScim(
const userMatch = pathname.match(/^\/scim\/v2\/Users\/([^/]+)$/);
if (userMatch) {
const userSub = decodeURIComponent(userMatch[1] as string);
- if (method === "GET") return sendJson(response, 200, await getScimUser(context, userSub));
+ if (method === "GET")
+ return sendJson(response, 200, await getScimUser(context, bearer, userSub));
if (method === "PUT") {
const body = parseJsonSafely(await readBody(request));
- const resource = await replaceScimUser(context, userSub, body as Record);
+ const resource = await replaceScimUser(
+ context,
+ bearer,
+ userSub,
+ body as Record
+ );
await auditScimEvent(context, request, {
eventType: "SCIM_USER_UPDATE",
resourceType: "scim_user",
resourceId: resource.id,
- tokenId: bearer.id,
+ tokenId: bearer.tokenId,
+ organizationId: bearer.organizationId,
+ connectionId: bearer.id,
details: scimUserAuditDetails("update", resource),
});
return sendJson(response, 200, resource);
}
if (method === "PATCH") {
const parsed = PatchSchema.parse(parseJsonSafely(await readBody(request)));
- const resource = await patchScimUser(context, userSub, parsed.Operations);
+ const resource = await patchScimUser(context, bearer, userSub, parsed.Operations);
await auditScimEvent(context, request, {
eventType: "SCIM_USER_PATCH",
resourceType: "scim_user",
resourceId: resource.id,
- tokenId: bearer.id,
+ tokenId: bearer.tokenId,
+ organizationId: bearer.organizationId,
+ connectionId: bearer.id,
details: scimUserAuditDetails("patch", resource),
});
return sendJson(response, 200, resource);
}
if (method === "DELETE") {
- const resource = await deactivateScimUser(context, userSub);
+ const resource = await deactivateScimUser(context, bearer, userSub);
await auditScimEvent(context, request, {
eventType: "SCIM_USER_DEACTIVATE",
resourceType: "scim_user",
resourceId: resource.id,
- tokenId: bearer.id,
+ tokenId: bearer.tokenId,
+ organizationId: bearer.organizationId,
+ connectionId: bearer.id,
details: scimUserAuditDetails("deactivate", resource),
});
return sendJson(response, 200, resource);
@@ -235,15 +254,17 @@ export async function handleScim(
if (pathname === "/scim/v2/Groups") {
if (method === "GET")
- return sendJson(response, 200, await listScimGroups(context, query(request)));
+ return sendJson(response, 200, await listScimGroups(context, bearer, query(request)));
if (method === "POST") {
const body = parseJsonSafely(await readBody(request));
- const resource = await createScimGroup(context, body as Record);
+ const resource = await createScimGroup(context, bearer, body as Record);
await auditScimEvent(context, request, {
eventType: "SCIM_GROUP_CREATE",
resourceType: "scim_group",
resourceId: resource.id,
- tokenId: bearer.id,
+ tokenId: bearer.tokenId,
+ organizationId: bearer.organizationId,
+ connectionId: bearer.id,
statusCode: 201,
details: scimGroupAuditDetails("create", resource),
});
@@ -254,27 +275,32 @@ export async function handleScim(
const groupMatch = pathname.match(/^\/scim\/v2\/Groups\/([^/]+)$/);
if (groupMatch) {
const groupId = decodeURIComponent(groupMatch[1] as string);
- if (method === "GET") return sendJson(response, 200, await getScimGroup(context, groupId));
+ if (method === "GET")
+ return sendJson(response, 200, await getScimGroup(context, bearer, groupId));
if (method === "PATCH") {
const parsed = PatchSchema.parse(parseJsonSafely(await readBody(request)));
- const resource = await patchScimGroup(context, groupId, parsed.Operations);
+ const resource = await patchScimGroup(context, bearer, groupId, parsed.Operations);
await auditScimEvent(context, request, {
eventType: "SCIM_GROUP_PATCH",
resourceType: "scim_group",
resourceId: resource.id,
- tokenId: bearer.id,
+ tokenId: bearer.tokenId,
+ organizationId: bearer.organizationId,
+ connectionId: bearer.id,
details: scimGroupAuditDetails("patch", resource),
});
return sendJson(response, 200, resource);
}
if (method === "DELETE") {
- const resource = await getScimGroup(context, groupId);
- const result = await deleteScimGroup(context, groupId);
+ const resource = await getScimGroup(context, bearer, groupId);
+ const result = await deleteScimGroup(context, bearer, groupId);
await auditScimEvent(context, request, {
eventType: "SCIM_GROUP_DELETE",
resourceType: "scim_group",
resourceId: resource.id,
- tokenId: bearer.id,
+ tokenId: bearer.tokenId,
+ organizationId: bearer.organizationId,
+ connectionId: bearer.id,
details: scimGroupAuditDetails("delete", resource),
});
return sendJson(response, 200, result);
diff --git a/packages/api/src/controllers/user/authorize.ts b/packages/api/src/controllers/user/authorize.ts
index 03d8a95c..26246f5e 100644
--- a/packages/api/src/controllers/user/authorize.ts
+++ b/packages/api/src/controllers/user/authorize.ts
@@ -175,6 +175,8 @@ export const schema = {
path: "/authorize",
tags: ["Auth"],
summary: "Authorization endpoint",
+ description:
+ "Accepts organization_id as an authorization context hint. If no organization context can be resolved during finalization for a multi-organization user, the flow returns ORG_CONTEXT_REQUIRED.",
query: AuthorizationRequestSchema,
responses: { 302: { description: "Redirect to UI", ...genericErrors } },
} as const satisfies ControllerSchema;
diff --git a/packages/api/src/controllers/user/authorizeFinalize.ts b/packages/api/src/controllers/user/authorizeFinalize.ts
index 59b64e8b..7bfcbe77 100644
--- a/packages/api/src/controllers/user/authorizeFinalize.ts
+++ b/packages/api/src/controllers/user/authorizeFinalize.ts
@@ -239,6 +239,8 @@ export const schema = {
path: "/authorize/finalize",
tags: ["Auth"],
summary: "Finalize authorization after login",
+ description:
+ "Issues an authorization code for the selected organization. If the user has multiple active organizations and none is selected or stored in the session, the response uses ORG_CONTEXT_REQUIRED.",
body: {
description: "",
required: true,
diff --git a/packages/api/src/controllers/user/authorizeOrganizationSwitch.test.ts b/packages/api/src/controllers/user/authorizeOrganizationSwitch.test.ts
index 7f6206fe..1a87a90c 100644
--- a/packages/api/src/controllers/user/authorizeOrganizationSwitch.test.ts
+++ b/packages/api/src/controllers/user/authorizeOrganizationSwitch.test.ts
@@ -222,3 +222,55 @@ test("authorize finalization allows switching away from the session organization
await cleanup();
}
});
+
+test("authorize accepts organization_id for Atlas public PKCE clients", async () => {
+ const { context, cleanup } = await createContext();
+ try {
+ const { defaultOrganizationId } = await createUserWithTwoOrganizations(context);
+ await createClient(context, {
+ clientId: "atlas",
+ name: "Atlas",
+ type: "public",
+ requirePkce: true,
+ redirectUris: ["https://atlas.example/callback"],
+ scopes: ["openid", "profile"],
+ });
+
+ const response = createResponse();
+ const params = new URLSearchParams({
+ client_id: "atlas",
+ redirect_uri: "https://atlas.example/callback",
+ response_type: "code",
+ scope: "openid profile",
+ state: "atlas-state",
+ organization_id: defaultOrganizationId,
+ code_challenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ code_challenge_method: "S256",
+ });
+
+ await getAuthorize(
+ context,
+ createRequest({
+ method: "GET",
+ url: `/authorize?${params.toString()}`,
+ sessionId: "session-id",
+ }),
+ response
+ );
+
+ assert.equal(response.statusCode, 302);
+ const location = response.headers.Location;
+ assert.equal(typeof location, "string");
+ const redirectParams = new URL(location as string, "https://auth.example.com").searchParams;
+ assert.equal(redirectParams.get("organization_id"), defaultOrganizationId);
+ const requestId = redirectParams.get("request_id");
+ assert.ok(requestId);
+ const pendingRequest = await getPendingAuth(context, requestId);
+ assert.equal(pendingRequest?.clientId, "atlas");
+ assert.equal(pendingRequest?.organizationId, defaultOrganizationId);
+ assert.equal(pendingRequest?.codeChallenge, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+ assert.equal(pendingRequest?.codeChallengeMethod, "S256");
+ } finally {
+ await cleanup();
+ }
+});
diff --git a/packages/api/src/controllers/user/enterpriseConnections.ts b/packages/api/src/controllers/user/enterpriseConnections.ts
new file mode 100644
index 00000000..c7e43d49
--- /dev/null
+++ b/packages/api/src/controllers/user/enterpriseConnections.ts
@@ -0,0 +1,459 @@
+import type { IncomingMessage, ServerResponse } from "node:http";
+import { z } from "zod/v4";
+import { ValidationError } from "../../errors.ts";
+import {
+ createFederationConnection,
+ createFederationConnectionDomainForOrganization,
+ deleteFederationConnectionDomainForOrganization,
+ deleteFederationConnectionForOrganization,
+ getFederationConnectionForOrganization,
+ listFederationConnectionDomainsForOrganization,
+ listFederationConnectionsForOrganization,
+ runFederationDomainDnsVerificationForOrganization,
+ updateFederationConnectionForOrganization,
+} from "../../models/federation.ts";
+import { requireOrganizationManagePermission } from "../../models/organizations.ts";
+import {
+ createScimBearerTokenForConnection,
+ createScimConnection,
+ deleteScimConnectionForOrg,
+ getScimConnectionForOrg,
+ listScimBearerTokensForConnection,
+ listScimConnectionsForOrganization,
+ revokeScimBearerTokenForConnection,
+ updateScimConnectionForOrg,
+} from "../../models/scim.ts";
+import { requireSession } from "../../services/sessions.ts";
+import type { Context } from "../../types.ts";
+import { withAudit } from "../../utils/auditWrapper.ts";
+import { parseJsonSafely, readBody, sendJson } from "../../utils/http.ts";
+
+const ClaimMappingSchema = z
+ .object({
+ subject: z.string().min(1).optional(),
+ email: z.string().min(1).optional(),
+ emailVerified: z.string().min(1).optional(),
+ name: z.string().min(1).optional(),
+ groups: z.string().min(1).optional(),
+ })
+ .optional();
+
+const FederationConnectionRequestSchema = z.object({
+ name: z.string().min(1).max(255),
+ issuer: z.string().url(),
+ clientId: z.string().min(1).max(255),
+ clientSecret: z.string().min(1).nullable().optional(),
+ discoveryUrl: z.string().url().optional(),
+ authorizationEndpoint: z.string().url().optional(),
+ tokenEndpoint: z.string().url().optional(),
+ jwksUri: z.string().url().optional(),
+ userinfoEndpoint: z.string().url().nullable().optional(),
+ scopes: z.array(z.string().min(1)).optional(),
+ claimMapping: ClaimMappingSchema,
+ accountLinkingPolicy: z.enum(["disabled", "email_verified"]).optional(),
+ jitProvisioning: z.boolean().optional(),
+ membershipOnAuthentication: z.boolean().optional(),
+ requireScimPreProvisioning: z.boolean().optional(),
+ enabled: z.boolean().optional(),
+});
+
+const FederationConnectionUpdateSchema = FederationConnectionRequestSchema.partial();
+
+function enterpriseAuditContext(
+ organizationId: string,
+ enterpriseConnectionType: "federation" | "scim",
+ enterpriseConnectionId?: string
+) {
+ return { organizationId, enterpriseConnectionId, enterpriseConnectionType };
+}
+
+// Federation / SSO
+
+export async function getOrganizationFederationConnections(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const connections = await listFederationConnectionsForOrganization(context, orgId);
+ sendJson(response, 200, { connections });
+}
+
+export const postOrganizationFederationConnection = withAudit({
+ eventType: "USER_ORG_FEDERATION_CONNECTION_CREATE",
+ resourceType: "federation_connection",
+ extractAuditContext: (_body, responseData, params) =>
+ enterpriseAuditContext(
+ params[0] as string,
+ "federation",
+ responseData && typeof responseData === "object"
+ ? (responseData as { connection?: { id?: string } }).connection?.id
+ : undefined
+ ),
+})(async function postOrganizationFederationConnection(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const raw = parseJsonSafely(await readBody(request));
+ const parsed = FederationConnectionRequestSchema.safeParse(raw);
+ if (!parsed.success) throw new ValidationError("Validation error", parsed.error.issues);
+ const connection = await createFederationConnection(context, {
+ ...parsed.data,
+ organizationId: orgId,
+ });
+ sendJson(response, 201, { connection });
+});
+
+export async function getOrganizationFederationConnection(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const connection = await getFederationConnectionForOrganization(context, orgId, connectionId);
+ sendJson(response, 200, { connection });
+}
+
+export const putOrganizationFederationConnection = withAudit({
+ eventType: "USER_ORG_FEDERATION_CONNECTION_UPDATE",
+ resourceType: "federation_connection",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "federation", params[1]),
+})(async function putOrganizationFederationConnection(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const raw = parseJsonSafely(await readBody(request));
+ const parsed = FederationConnectionUpdateSchema.safeParse(raw);
+ if (!parsed.success) throw new ValidationError("Validation error", parsed.error.issues);
+ const connection = await updateFederationConnectionForOrganization(
+ context,
+ orgId,
+ connectionId,
+ parsed.data
+ );
+ sendJson(response, 200, { connection });
+});
+
+export const deleteOrganizationFederationConnection = withAudit({
+ eventType: "USER_ORG_FEDERATION_CONNECTION_DELETE",
+ resourceType: "federation_connection",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "federation", params[1]),
+ skipBodyCapture: true,
+})(async function deleteOrganizationFederationConnection(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const result = await deleteFederationConnectionForOrganization(context, orgId, connectionId);
+ sendJson(response, 200, result);
+});
+
+export async function getOrganizationFederationDomains(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const domains = await listFederationConnectionDomainsForOrganization(
+ context,
+ orgId,
+ connectionId
+ );
+ sendJson(response, 200, {
+ domains: domains.map((domain) => ({
+ id: domain.id,
+ domain: domain.domain,
+ verificationStatus: domain.verificationStatus,
+ verifiedAt: domain.verifiedAt,
+ lastCheckedAt: domain.lastCheckedAt,
+ enabled: domain.enabled,
+ recordName: domain.recordName,
+ })),
+ });
+}
+
+export const postOrganizationFederationDomain = withAudit({
+ eventType: "USER_ORG_FEDERATION_DOMAIN_CREATE",
+ resourceType: "federation_connection_domain",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "federation", params[1]),
+})(async function postOrganizationFederationDomain(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const raw = parseJsonSafely(await readBody(request));
+ const parsed = z.object({ domain: z.string().min(1) }).safeParse(raw);
+ if (!parsed.success) throw new ValidationError("Validation error", parsed.error.issues);
+ const created = await createFederationConnectionDomainForOrganization(
+ context,
+ orgId,
+ connectionId,
+ parsed.data.domain
+ );
+ sendJson(response, 201, {
+ id: created.id,
+ domain: created.domain,
+ verificationStatus: created.verificationStatus,
+ recordName: created.recordName,
+ recordValue: created.recordValue,
+ });
+});
+
+export const deleteOrganizationFederationDomain = withAudit({
+ eventType: "USER_ORG_FEDERATION_DOMAIN_DELETE",
+ resourceType: "federation_connection_domain",
+ extractResourceId: (_body: unknown, params: string[]) => params[2],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "federation", params[1]),
+ skipBodyCapture: true,
+})(async function deleteOrganizationFederationDomain(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string,
+ domainId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const result = await deleteFederationConnectionDomainForOrganization(
+ context,
+ orgId,
+ connectionId,
+ domainId
+ );
+ sendJson(response, 200, result);
+});
+
+export const postOrganizationFederationDomainVerify = withAudit({
+ eventType: "USER_ORG_FEDERATION_DOMAIN_VERIFY",
+ resourceType: "federation_connection_domain",
+ extractResourceId: (_body: unknown, params: string[]) => params[2],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "federation", params[1]),
+ skipBodyCapture: true,
+})(async function postOrganizationFederationDomainVerify(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string,
+ domainId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const result = await runFederationDomainDnsVerificationForOrganization(
+ context,
+ orgId,
+ connectionId,
+ domainId
+ );
+ sendJson(response, 200, {
+ id: result.domain.id,
+ domain: result.domain.domain,
+ verificationStatus: result.domain.verificationStatus,
+ lastCheckedAt: result.domain.lastCheckedAt,
+ });
+});
+
+// SCIM
+
+export async function getOrganizationScimConnections(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const connections = await listScimConnectionsForOrganization(context, orgId);
+ sendJson(response, 200, { connections });
+}
+
+export const postOrganizationScimConnection = withAudit({
+ eventType: "USER_ORG_SCIM_CONNECTION_CREATE",
+ resourceType: "scim_connection",
+ extractAuditContext: (_body, responseData, params) =>
+ enterpriseAuditContext(
+ params[0] as string,
+ "scim",
+ responseData && typeof responseData === "object"
+ ? (responseData as { connection?: { id?: string } }).connection?.id
+ : undefined
+ ),
+})(async function postOrganizationScimConnection(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const raw = parseJsonSafely(await readBody(request));
+ const parsed = z.object({ name: z.string().min(1) }).safeParse(raw);
+ if (!parsed.success) throw new ValidationError("Validation error", parsed.error.issues);
+ const connection = await createScimConnection(context, {
+ organizationId: orgId,
+ name: parsed.data.name,
+ });
+ sendJson(response, 201, { connection });
+});
+
+export async function getOrganizationScimConnection(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const connection = await getScimConnectionForOrg(context, orgId, connectionId);
+ sendJson(response, 200, { connection });
+}
+
+export const putOrganizationScimConnection = withAudit({
+ eventType: "USER_ORG_SCIM_CONNECTION_UPDATE",
+ resourceType: "scim_connection",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "scim", params[1]),
+})(async function putOrganizationScimConnection(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const raw = parseJsonSafely(await readBody(request));
+ const parsed = z
+ .object({
+ name: z.string().min(1).optional(),
+ enabled: z.boolean().optional(),
+ deprovisionAction: z
+ .enum(["suspend_membership", "remove_membership", "delete_user"])
+ .optional(),
+ deleteUserSafety: z.enum(["fail_closed", "suspend_membership"]).optional(),
+ })
+ .safeParse(raw);
+ if (!parsed.success) throw new ValidationError("Validation error", parsed.error.issues);
+ const connection = await updateScimConnectionForOrg(context, orgId, connectionId, parsed.data);
+ sendJson(response, 200, { connection });
+});
+
+export const deleteOrganizationScimConnection = withAudit({
+ eventType: "USER_ORG_SCIM_CONNECTION_DELETE",
+ resourceType: "scim_connection",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "scim", params[1]),
+ skipBodyCapture: true,
+})(async function deleteOrganizationScimConnection(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const result = await deleteScimConnectionForOrg(context, orgId, connectionId);
+ sendJson(response, 200, result);
+});
+
+export async function getOrganizationScimTokens(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const tokens = await listScimBearerTokensForConnection(context, orgId, connectionId);
+ sendJson(response, 200, { tokens });
+}
+
+export const postOrganizationScimToken = withAudit({
+ eventType: "USER_ORG_SCIM_TOKEN_CREATE",
+ resourceType: "scim_token",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "scim", params[1]),
+})(async function postOrganizationScimToken(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const raw = parseJsonSafely(await readBody(request));
+ const parsed = z
+ .object({
+ name: z.string().min(1).optional(),
+ expiresAt: z.string().datetime().nullable().optional(),
+ })
+ .safeParse(raw);
+ if (!parsed.success) throw new ValidationError("Validation error", parsed.error.issues);
+ const token = await createScimBearerTokenForConnection(context, orgId, connectionId, {
+ name: parsed.data.name,
+ expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : null,
+ });
+ sendJson(response, 201, token);
+});
+
+export const deleteOrganizationScimToken = withAudit({
+ eventType: "USER_ORG_SCIM_TOKEN_REVOKE",
+ resourceType: "scim_token",
+ extractResourceId: (_body: unknown, params: string[]) => params[2],
+ extractAuditContext: (_body, _responseData, params) =>
+ enterpriseAuditContext(params[0] as string, "scim", params[1]),
+ skipBodyCapture: true,
+})(async function deleteOrganizationScimToken(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ orgId: string,
+ connectionId: string,
+ tokenId: string
+) {
+ const session = await requireSession(context, request, false);
+ await requireOrganizationManagePermission(context, session.sub as string, orgId);
+ const result = await revokeScimBearerTokenForConnection(context, orgId, connectionId, tokenId);
+ sendJson(response, 200, result);
+});
diff --git a/packages/api/src/controllers/user/federationOidc.test.ts b/packages/api/src/controllers/user/federationOidc.test.ts
index 0bd00c83..d76dc973 100644
--- a/packages/api/src/controllers/user/federationOidc.test.ts
+++ b/packages/api/src/controllers/user/federationOidc.test.ts
@@ -305,6 +305,10 @@ test("federation callback validates ID token, links account, and creates locked
const sessionRows = await context.db.select().from(sessions);
assert.equal(sessionRows.length, 1);
assert.equal((sessionRows[0]?.data as { keyState?: string }).keyState, "locked");
+ assert.equal(
+ (sessionRows[0]?.data as { organizationId?: string }).organizationId,
+ connection.organizationId
+ );
const identities = await context.db.select().from(federationIdentities);
assert.equal(identities.length, 1);
assert.equal(identities[0]?.externalSubject, "external-sub");
diff --git a/packages/api/src/controllers/user/federationOidc.ts b/packages/api/src/controllers/user/federationOidc.ts
index d9ba1ce8..e999b348 100644
--- a/packages/api/src/controllers/user/federationOidc.ts
+++ b/packages/api/src/controllers/user/federationOidc.ts
@@ -32,6 +32,7 @@ const StartQuerySchema = z
.object({
connection_id: z.string().trim().min(1).optional(),
email: z.string().email().optional(),
+ organization_id: z.string().uuid().optional(),
return_to: z.string().trim().max(2048).optional(),
})
.refine((value) => value.connection_id || value.email, "connection_id or email is required");
@@ -58,13 +59,19 @@ export async function getFederationStart(
const parsed = StartQuerySchema.parse(Object.fromEntries(url.searchParams.entries()));
const connection = parsed.connection_id
? await getFederationConnectionSecret(context, parsed.connection_id)
- : await findConnectionForEmail(context, parsed.email as string);
+ : await findConnectionForEmail(context, parsed.email as string, parsed.organization_id);
if (!connection.enabled) throw new ValidationError("Federation connection is disabled");
+ if (parsed.organization_id && parsed.organization_id !== connection.organizationId) {
+ throw new ValidationError("Federation connection does not belong to the selected organization");
+ }
const nonce = generateRandomString(32);
const codeVerifier = generateRandomString(64);
+ const clientId = await getUserClientId(context);
const state = await createOidcCallbackState(context, {
connectionId: connection.id,
+ organizationId: connection.organizationId,
+ clientId,
nonce,
codeVerifier,
returnTo: normalizeReturnTo(context, parsed.return_to),
@@ -85,7 +92,12 @@ export async function getFederationStart(
success: true,
statusCode: 302,
resourceId: connection.id,
- details: { issuer: connection.issuer, return_to: normalizeReturnTo(context, parsed.return_to) },
+ details: {
+ issuer: connection.issuer,
+ organization_id: connection.organizationId,
+ client_id: clientId,
+ return_to: normalizeReturnTo(context, parsed.return_to),
+ },
});
redirect(response, authorizationUrl.toString(), 302);
}
@@ -127,6 +139,9 @@ export async function getFederationCallback(
auditConnectionId = stateRow.connectionId;
const connection = await getFederationConnectionSecret(context, stateRow.connectionId);
if (!connection.enabled) throw new ValidationError("Federation connection is disabled");
+ if (stateRow.organizationId !== connection.organizationId) {
+ throw new ValidationError("Federation state organization mismatch");
+ }
const tokenResponse = await exchangeCode(context, connection, code, cookie.codeVerifier);
const idTokenClaims = await validateIdToken(connection, tokenResponse.id_token, cookie.nonce);
@@ -144,23 +159,19 @@ export async function getFederationCallback(
const activeMemberships = (await getUserOrganizations(context, user.sub)).filter(
(membership) => membership.status === "active"
);
- if (activeMemberships.length === 0) throw new UnauthorizedError("Authentication not permitted");
- const sessionOrganization =
- activeMemberships.length === 1
- ? {
- organizationId: activeMemberships[0]?.organizationId,
- organizationSlug: activeMemberships[0]?.slug,
- }
- : {};
- const userClientId = await getUserClientId(context);
+ const sessionMembership = activeMemberships.find(
+ (membership) => membership.organizationId === connection.organizationId
+ );
+ if (!sessionMembership) throw new UnauthorizedError("Authentication not permitted");
const { sessionId, refreshToken } = await createSession(context, "user", {
sub: user.sub,
email: user.email || undefined,
name: user.name || undefined,
- ...sessionOrganization,
- clientId: userClientId,
+ organizationId: sessionMembership.organizationId,
+ organizationSlug: sessionMembership.slug,
+ clientId: stateRow.clientId || (await getUserClientId(context)),
keyState: "locked",
- otpRequired: activeMemberships.some((membership) => membership.forceOtp),
+ otpRequired: sessionMembership.forceOtp,
otpVerified: false,
});
const ttlSeconds = await getSessionTtlSeconds(context, "user");
@@ -174,7 +185,11 @@ export async function getFederationCallback(
statusCode: 302,
resourceId: connection.id,
userId: user.sub,
- details: { identity_id: auditIdentityId, issuer: connection.issuer },
+ details: {
+ identity_id: auditIdentityId,
+ issuer: connection.issuer,
+ organization_id: connection.organizationId,
+ },
});
}
await auditFederationEvent(context, request, {
@@ -183,7 +198,11 @@ export async function getFederationCallback(
statusCode: 302,
resourceId: connection.id,
userId: user.sub,
- details: { identity_id: auditIdentityId, issuer: connection.issuer },
+ details: {
+ identity_id: auditIdentityId,
+ issuer: connection.issuer,
+ organization_id: connection.organizationId,
+ },
});
await auditFederationEvent(context, request, {
eventType: "FEDERATION_LOGIN",
@@ -191,7 +210,11 @@ export async function getFederationCallback(
statusCode: 302,
resourceId: connection.id,
userId: user.sub,
- details: { key_state: "locked", issuer: connection.issuer },
+ details: {
+ key_state: "locked",
+ issuer: connection.issuer,
+ organization_id: connection.organizationId,
+ },
});
redirect(response, stateRow.returnTo || "/dashboard", 302);
} catch (error) {
@@ -244,8 +267,8 @@ async function auditFederationEvent(
});
}
-async function findConnectionForEmail(context: Context, email: string) {
- const connection = await findFederationConnectionForEmail(context, email);
+async function findConnectionForEmail(context: Context, email: string, organizationId?: string) {
+ const connection = await findFederationConnectionForEmail(context, email, { organizationId });
if (!connection) throw new ValidationError("No federation connection matches email domain");
return await getFederationConnectionSecret(context, connection.id);
}
diff --git a/packages/api/src/controllers/user/federationRoute.ts b/packages/api/src/controllers/user/federationRoute.ts
index 66aa51a6..db3e4358 100644
--- a/packages/api/src/controllers/user/federationRoute.ts
+++ b/packages/api/src/controllers/user/federationRoute.ts
@@ -9,6 +9,8 @@ import { sendJsonValidated } from "../../utils/http.ts";
const ConnectionSchema = z.object({
id: z.string().uuid(),
type: z.literal("oidc"),
+ protocol: z.string(),
+ organizationId: z.string().uuid(),
name: z.string(),
issuer: z.string(),
clientId: z.string(),
@@ -20,6 +22,13 @@ const ConnectionSchema = z.object({
scopes: z.array(z.string()),
claimMapping: z.record(z.string(), z.unknown()),
accountLinkingPolicy: z.enum(["disabled", "email_verified", "email"]),
+ jitProvisioning: z.boolean(),
+ membershipOnAuthentication: z.boolean(),
+ requireScimPreProvisioning: z.boolean(),
+ requirePasswordForZk: z.boolean(),
+ allowPasskeyPrf: z.boolean(),
+ allowTrustedDeviceApproval: z.boolean(),
+ allowNonZkKeySetupBypass: z.boolean(),
domains: z.array(z.string()),
enabled: z.boolean(),
metadata: z.record(z.string(), z.unknown()),
@@ -40,7 +49,8 @@ export async function getFederationRoute(
const url = new URL(request.url || "", `http://${request.headers.host}`);
const email = url.searchParams.get("email");
if (!email) throw new ValidationError("email is required");
- const connection = await findFederationConnectionForEmail(context, email);
+ const organizationId = url.searchParams.get("organization_id") || undefined;
+ const connection = await findFederationConnectionForEmail(context, email, { organizationId });
sendJsonValidated(response, 200, { connection }, ResponseSchema);
}
@@ -49,7 +59,7 @@ export const schema = {
path: "/federation/route",
tags: ["Federation"],
summary: "Resolve federation connection for email domain",
- query: z.object({ email: z.string().email() }),
+ query: z.object({ email: z.string().email(), organization_id: z.string().uuid().optional() }),
responses: {
200: {
description: "OK",
diff --git a/packages/api/src/controllers/user/orgTokenContext.test.ts b/packages/api/src/controllers/user/orgTokenContext.test.ts
new file mode 100644
index 00000000..7063bae7
--- /dev/null
+++ b/packages/api/src/controllers/user/orgTokenContext.test.ts
@@ -0,0 +1,336 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import type { IncomingMessage, ServerResponse } from "node:http";
+import os from "node:os";
+import path from "node:path";
+import { Readable } from "node:stream";
+import { test } from "node:test";
+import { decodeJwt } from "jose";
+import { createPglite } from "../../db/pglite.ts";
+import { clients, organizationMembers, organizations, users } from "../../db/schema.ts";
+import { AppError } from "../../errors.ts";
+import { createPersonalOrganizationForUser } from "../../models/organizations.ts";
+import { userOpaqueRegisterFinish } from "../../models/registration.ts";
+import { generateEdDSAKeyPair, storeKeyPair } from "../../services/jwks.ts";
+import { createSession } from "../../services/sessions.ts";
+import type { Context } from "../../types.ts";
+import { postSessionOrganization } from "./session.ts";
+import { postToken } from "./token.ts";
+
+function createLogger() {
+ return {
+ error() {},
+ warn() {},
+ info() {},
+ debug() {},
+ trace() {},
+ fatal() {},
+ };
+}
+
+async function createContext() {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-org-token-test-"));
+ const { db, close } = await createPglite(directory);
+ const context = {
+ db,
+ config: {
+ issuer: "http://localhost:9080",
+ publicOrigin: "http://localhost:9080",
+ postgresUri: "",
+ userPort: 9080,
+ adminPort: 9081,
+ proxyUi: false,
+ kekPassphrase: "test",
+ isDevelopment: true,
+ rpId: "localhost",
+ },
+ services: {
+ kek: {
+ encrypt: async (data: Buffer) => Buffer.from(data),
+ decrypt: async (data: Buffer) => Buffer.from(data),
+ isAvailable: () => true,
+ },
+ opaque: {
+ finishRegistration: async () => ({
+ envelope: new Uint8Array([1, 2, 3]),
+ serverPublicKey: new Uint8Array([4, 5, 6]),
+ }),
+ },
+ },
+ logger: createLogger(),
+ cleanupFunctions: [],
+ destroy: async () => {},
+ } as unknown as Context;
+ const { kid, publicJwk, privateJwk } = await generateEdDSAKeyPair();
+ await storeKeyPair(context, kid, publicJwk, privateJwk);
+ const cleanup = async () => {
+ await close();
+ fs.rmSync(directory, { recursive: true, force: true });
+ };
+ return { context, cleanup };
+}
+
+function createRequest(options: {
+ method?: string;
+ url?: string;
+ body?: string;
+ sessionId?: string;
+}): IncomingMessage {
+ const request = Readable.from(options.body ? [options.body] : []) as IncomingMessage;
+ request.method = options.method || "POST";
+ request.url = options.url || "/token";
+ request.headers = {
+ host: "localhost",
+ "user-agent": "node-test",
+ cookie: options.sessionId ? `__Host-DarkAuth-User=${options.sessionId}` : "",
+ };
+ request.socket = { remoteAddress: "127.0.0.1" } as IncomingMessage["socket"];
+ return request;
+}
+
+function createResponse(): ServerResponse & { body: string; json: unknown } {
+ let body = "";
+ const headers = new Map();
+ return {
+ statusCode: 0,
+ setHeader(name: string, value: string | string[] | number) {
+ headers.set(name.toLowerCase(), value);
+ return this;
+ },
+ getHeader(name: string) {
+ return headers.get(name.toLowerCase());
+ },
+ end(chunk?: unknown) {
+ if (chunk !== undefined) body += String(chunk);
+ return this;
+ },
+ get body() {
+ return body;
+ },
+ get json() {
+ return body ? JSON.parse(body) : undefined;
+ },
+ } as ServerResponse & { body: string; json: unknown };
+}
+
+async function createPublicRefreshClient(context: Context) {
+ await context.db.insert(clients).values({
+ clientId: "public-refresh-client",
+ name: "Public Refresh",
+ type: "public",
+ tokenEndpointAuthMethod: "none",
+ redirectUris: ["https://app.example.com/callback"],
+ postLogoutRedirectUris: [],
+ grantTypes: ["authorization_code", "refresh_token"],
+ });
+}
+
+test("issued token after personal organization creation carries org context and personal-org roles", async () => {
+ const { context, cleanup } = await createContext();
+ try {
+ const registered = await userOpaqueRegisterFinish(context, {
+ record: new Uint8Array([9, 9, 9]),
+ email: "claims-user@example.com",
+ name: "Claims User",
+ });
+ assert.ok(registered.sessionId);
+
+ const personalOrg = await context.db.query.organizations.findFirst({
+ where: (table, { eq }) => eq(table.createdByUserSub, registered.sub),
+ });
+ assert.ok(personalOrg);
+
+ await createPublicRefreshClient(context);
+ const issued = await createSession(context, "user", {
+ sub: registered.sub,
+ clientId: "public-refresh-client",
+ scope: "openid profile",
+ organizationId: personalOrg?.id,
+ organizationSlug: personalOrg?.slug,
+ });
+
+ const request = createRequest({
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: issued.refreshToken,
+ client_id: "public-refresh-client",
+ }).toString(),
+ });
+ const response = createResponse();
+ await postToken(context, request, response);
+
+ const json = response.json as { access_token: string; id_token: string };
+ assert.equal(response.statusCode, 200);
+ const claims = decodeJwt(json.access_token) as Record;
+ assert.equal(claims.org_id, personalOrg?.id);
+ assert.equal(claims.org_slug, personalOrg?.slug);
+ assert.deepEqual((claims.roles as string[]).slice().sort(), ["member", "org_admin"]);
+ const idClaims = decodeJwt(json.id_token) as Record;
+ assert.equal(idClaims.org_id, personalOrg?.id);
+ assert.deepEqual((idClaims.roles as string[]).slice().sort(), ["member", "org_admin"]);
+ assert.ok((idClaims.permissions as string[]).includes("darkauth.org:manage"));
+ } finally {
+ await cleanup();
+ }
+});
+
+test("multi-org refresh without session organization requires org context", async () => {
+ const { context, cleanup } = await createContext();
+ try {
+ await context.db.insert(users).values({
+ sub: "multi-user",
+ email: "multi@example.com",
+ name: "Multi Org",
+ emailVerifiedAt: new Date(),
+ });
+ const orgA = await createPersonalOrganizationForUser(context.db, "multi-user", "Multi Org");
+ const [orgB] = await context.db
+ .insert(organizations)
+ .values({ slug: "second-org", name: "Second Org", createdByUserSub: "multi-user" })
+ .returning();
+ assert.ok(orgB);
+ await context.db.insert(organizationMembers).values({
+ organizationId: orgB.id,
+ userSub: "multi-user",
+ status: "active",
+ });
+
+ await createPublicRefreshClient(context);
+
+ const noOrgSession = await createSession(context, "user", {
+ sub: "multi-user",
+ clientId: "public-refresh-client",
+ scope: "openid profile",
+ });
+ const missingRequest = createRequest({
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: noOrgSession.refreshToken,
+ client_id: "public-refresh-client",
+ }).toString(),
+ });
+ await assert.rejects(
+ () => postToken(context, missingRequest, createResponse()),
+ (error: unknown) => error instanceof AppError && error.code === "ORG_CONTEXT_REQUIRED"
+ );
+
+ const staleOrgSession = await createSession(context, "user", {
+ sub: "multi-user",
+ clientId: "public-refresh-client",
+ scope: "openid profile",
+ organizationId: "00000000-0000-4000-8000-000000000000",
+ });
+ const staleRequest = createRequest({
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: staleOrgSession.refreshToken,
+ client_id: "public-refresh-client",
+ }).toString(),
+ });
+ await assert.rejects(
+ () => postToken(context, staleRequest, createResponse()),
+ (error: unknown) => error instanceof AppError && error.code === "ORG_CONTEXT_REQUIRED"
+ );
+
+ const validOrgSession = await createSession(context, "user", {
+ sub: "multi-user",
+ clientId: "public-refresh-client",
+ scope: "openid profile",
+ organizationId: orgA.organizationId,
+ organizationSlug: orgA.slug,
+ });
+ const validRequest = createRequest({
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: validOrgSession.refreshToken,
+ client_id: "public-refresh-client",
+ }).toString(),
+ });
+ const validResponse = createResponse();
+ await postToken(context, validRequest, validResponse);
+ const json = validResponse.json as { access_token: string };
+ assert.equal(validResponse.statusCode, 200);
+ const claims = decodeJwt(json.access_token) as Record;
+ assert.equal(claims.org_id, orgA.organizationId);
+ assert.equal(claims.org_slug, orgA.slug);
+ } finally {
+ await cleanup();
+ }
+});
+
+test("refresh grant uses organization selected through session organization endpoint", async () => {
+ const { context, cleanup } = await createContext();
+ try {
+ await context.db.insert(users).values({
+ sub: "switch-refresh-user",
+ email: "switch-refresh@example.com",
+ name: "Switch Refresh",
+ emailVerifiedAt: new Date(),
+ });
+ const orgA = await createPersonalOrganizationForUser(
+ context.db,
+ "switch-refresh-user",
+ "Switch Refresh",
+ { slug: "switch-refresh-a" }
+ );
+ const [orgB] = await context.db
+ .insert(organizations)
+ .values({
+ slug: "switch-refresh-b",
+ name: "Switch Refresh B",
+ createdByUserSub: "switch-refresh-user",
+ })
+ .returning();
+ assert.ok(orgB);
+ await context.db.insert(organizationMembers).values({
+ organizationId: orgB.id,
+ userSub: "switch-refresh-user",
+ status: "active",
+ });
+ await createPublicRefreshClient(context);
+ const issued = await createSession(context, "user", {
+ sub: "switch-refresh-user",
+ clientId: "public-refresh-client",
+ scope: "openid profile",
+ organizationId: orgA.organizationId,
+ organizationSlug: orgA.slug,
+ });
+
+ const switchResponse = createResponse();
+ await postSessionOrganization(
+ context,
+ createRequest({
+ method: "POST",
+ url: "/session/organization",
+ sessionId: issued.sessionId,
+ body: JSON.stringify({ organization_id: orgB.id }),
+ }),
+ switchResponse
+ );
+
+ assert.equal(switchResponse.statusCode, 200);
+ const refreshResponse = createResponse();
+ await postToken(
+ context,
+ createRequest({
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: issued.refreshToken,
+ client_id: "public-refresh-client",
+ }).toString(),
+ }),
+ refreshResponse
+ );
+
+ const json = refreshResponse.json as { access_token: string; id_token: string };
+ assert.equal(refreshResponse.statusCode, 200);
+ const accessClaims = decodeJwt(json.access_token) as Record;
+ assert.equal(accessClaims.org_id, orgB.id);
+ assert.equal(accessClaims.org_slug, "switch-refresh-b");
+ const idClaims = decodeJwt(json.id_token) as Record;
+ assert.equal(idClaims.org_id, orgB.id);
+ assert.equal(idClaims.org_slug, "switch-refresh-b");
+ } finally {
+ await cleanup();
+ }
+});
diff --git a/packages/api/src/controllers/user/organizations.ts b/packages/api/src/controllers/user/organizations.ts
index 2da5cd2c..eaf98510 100644
--- a/packages/api/src/controllers/user/organizations.ts
+++ b/packages/api/src/controllers/user/organizations.ts
@@ -5,9 +5,13 @@ import {
assignMemberRoles,
createOrganization,
createOrganizationInvite,
+ deleteOrganization,
+ leaveOrganization,
+ listAssignableRoles,
listOrganizationMembers,
listOrganizationsForUser,
removeMemberRole,
+ removeOrganizationMember,
requireOrganizationMembership,
} from "../../models/organizations.ts";
import { requireSession } from "../../services/sessions.ts";
@@ -24,19 +28,30 @@ const OrganizationSchema = z.object({
status: z.string(),
});
+const RoleSummarySchema = z.object({
+ id: z.string().uuid(),
+ key: z.string(),
+ name: z.string(),
+});
+
+const OrganizationListItemSchema = OrganizationSchema.extend({
+ roles: z.array(RoleSummarySchema),
+});
+
const MemberSchema = z.object({
membershipId: z.string().uuid(),
userSub: z.string(),
status: z.string(),
email: z.string().nullable().optional(),
name: z.string().nullable().optional(),
- roles: z.array(
- z.object({
- id: z.string().uuid(),
- key: z.string(),
- name: z.string(),
- })
- ),
+ roles: z.array(RoleSummarySchema),
+});
+
+const AssignableRoleSchema = z.object({
+ id: z.string().uuid(),
+ key: z.string(),
+ name: z.string(),
+ description: z.string().nullable().optional(),
});
export async function getOrganizations(
@@ -50,8 +65,16 @@ export async function getOrganizations(
}
export const postOrganizations = withAudit({
- eventType: "ORGANIZATION_CREATE",
+ eventType: "USER_ORG_CREATE",
resourceType: "organization",
+ extractResourceId: (_body: unknown, _params: string[]) => undefined,
+ extractAuditContext: (_body, responseData) => {
+ const org =
+ responseData && typeof responseData === "object"
+ ? (responseData as { organization?: { organizationId?: string } }).organization
+ : undefined;
+ return org?.organizationId ? { organizationId: org.organizationId } : undefined;
+ },
})(async function postOrganizations(
context: Context,
request: IncomingMessage,
@@ -91,9 +114,21 @@ export async function getOrganizationMembers(
sendJson(response, 200, { members });
}
+export async function getOrganizationAssignableRoles(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ organizationId: string
+) {
+ const session = await requireSession(context, request, false);
+ const roles = await listAssignableRoles(context, session.sub as string, organizationId);
+ sendJson(response, 200, { roles });
+}
+
export const postOrganizationInvites = withAudit({
- eventType: "ORGANIZATION_INVITE_CREATE",
+ eventType: "USER_ORG_MEMBER_ADD",
resourceType: "organization_invite",
+ extractAuditContext: (_body, _responseData, params) => ({ organizationId: params[0] }),
})(async function postOrganizationInvites(
context: Context,
request: IncomingMessage,
@@ -118,8 +153,10 @@ export const postOrganizationInvites = withAudit({
});
export const postOrganizationMemberRoles = withAudit({
- eventType: "ORGANIZATION_MEMBER_ROLES_ASSIGN",
+ eventType: "USER_ORG_MEMBER_ROLE_ADD",
resourceType: "organization_member",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) => ({ organizationId: params[0] }),
})(async function postOrganizationMemberRoles(
context: Context,
request: IncomingMessage,
@@ -150,8 +187,10 @@ export const postOrganizationMemberRoles = withAudit({
});
export const deleteOrganizationMemberRole = withAudit({
- eventType: "ORGANIZATION_MEMBER_ROLE_REMOVE",
+ eventType: "USER_ORG_MEMBER_ROLE_REMOVE",
resourceType: "organization_member",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) => ({ organizationId: params[0] }),
})(async function deleteOrganizationMemberRole(
context: Context,
request: IncomingMessage,
@@ -171,17 +210,76 @@ export const deleteOrganizationMemberRole = withAudit({
sendJson(response, 200, result);
});
+export const deleteOrganizationMember = withAudit({
+ eventType: "USER_ORG_MEMBER_REMOVE",
+ resourceType: "organization_member",
+ extractResourceId: (_body: unknown, params: string[]) => params[1],
+ extractAuditContext: (_body, _responseData, params) => ({ organizationId: params[0] }),
+})(async function deleteOrganizationMember(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ organizationId: string,
+ memberId: string
+) {
+ const session = await requireSession(context, request, false);
+ const result = await removeOrganizationMember(
+ context,
+ session.sub as string,
+ organizationId,
+ memberId
+ );
+ sendJson(response, 200, result);
+});
+
+export const postOrganizationLeave = withAudit({
+ eventType: "USER_ORG_LEAVE",
+ resourceType: "organization",
+ extractResourceId: (_body: unknown, params: string[]) => params[0],
+ extractAuditContext: (_body, _responseData, params) => ({ organizationId: params[0] }),
+})(async function postOrganizationLeave(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ organizationId: string
+) {
+ const session = await requireSession(context, request, false);
+ const result = await leaveOrganization(context, session.sub as string, organizationId);
+ sendJson(response, 200, result);
+});
+
+export const deleteOrganizationController = withAudit({
+ eventType: "USER_ORG_DELETE",
+ resourceType: "organization",
+ extractResourceId: (_body: unknown, params: string[]) => params[0],
+ extractAuditContext: (_body, _responseData, params) => ({ organizationId: params[0] }),
+})(async function deleteOrganizationController(
+ context: Context,
+ request: IncomingMessage,
+ response: ServerResponse,
+ organizationId: string
+) {
+ const session = await requireSession(context, request, false);
+ const body = parseJsonSafely(await readBody(request));
+ const Req = z.object({ confirm: z.literal(true) });
+ Req.parse(body);
+ const result = await deleteOrganization(context, session.sub as string, organizationId);
+ sendJson(response, 200, result);
+});
+
export const organizationsSchema = {
method: "GET",
path: "/organizations",
tags: ["Organizations"],
summary: "List organizations",
+ description:
+ "Lists the current user's active organization memberships with role summaries for app switcher UIs.",
responses: {
200: {
description: "OK",
content: {
"application/json": {
- schema: z.object({ organizations: z.array(OrganizationSchema) }),
+ schema: z.object({ organizations: z.array(OrganizationListItemSchema) }),
},
},
},
@@ -257,6 +355,25 @@ export const organizationMembersSchema = {
},
} as const satisfies ControllerSchema;
+export const organizationAssignableRolesSchema = {
+ method: "GET",
+ path: "/organizations/{organizationId}/roles/assignable",
+ tags: ["Organizations"],
+ summary: "List assignable organization roles",
+ params: z.object({ organizationId: z.string().uuid() }),
+ responses: {
+ 200: {
+ description: "OK",
+ content: {
+ "application/json": {
+ schema: z.object({ roles: z.array(AssignableRoleSchema) }),
+ },
+ },
+ },
+ ...genericErrors,
+ },
+} as const satisfies ControllerSchema;
+
export const organizationInvitesSchema = {
method: "POST",
path: "/organizations/{organizationId}/invites",
@@ -351,3 +468,54 @@ export const organizationMemberRoleDeleteSchema = {
...genericErrors,
},
} as const satisfies ControllerSchema;
+
+export const organizationMemberDeleteSchema = {
+ method: "DELETE",
+ path: "/organizations/{organizationId}/members/{memberId}",
+ tags: ["Organizations"],
+ summary: "Remove organization member",
+ params: z.object({ organizationId: z.string().uuid(), memberId: z.string().uuid() }),
+ responses: {
+ 200: {
+ description: "OK",
+ content: { "application/json": { schema: z.object({ success: z.boolean() }) } },
+ },
+ ...genericErrors,
+ },
+} as const satisfies ControllerSchema;
+
+export const organizationLeaveSchema = {
+ method: "POST",
+ path: "/organizations/{organizationId}/leave",
+ tags: ["Organizations"],
+ summary: "Leave organization",
+ params: z.object({ organizationId: z.string().uuid() }),
+ responses: {
+ 200: {
+ description: "OK",
+ content: { "application/json": { schema: z.object({ success: z.boolean() }) } },
+ },
+ ...genericErrors,
+ },
+} as const satisfies ControllerSchema;
+
+export const organizationDeleteSchema = {
+ method: "DELETE",
+ path: "/organizations/{organizationId}",
+ tags: ["Organizations"],
+ summary: "Delete organization",
+ params: z.object({ organizationId: z.string().uuid() }),
+ body: {
+ description: "",
+ required: true,
+ contentType: "application/json",
+ schema: z.object({ confirm: z.literal(true) }),
+ },
+ responses: {
+ 200: {
+ description: "OK",
+ content: { "application/json": { schema: z.object({ success: z.boolean() }) } },
+ },
+ ...genericErrors,
+ },
+} as const satisfies ControllerSchema;
diff --git a/packages/api/src/controllers/user/otpStatus.ts b/packages/api/src/controllers/user/otpStatus.ts
index e80abe1d..0829321b 100644
--- a/packages/api/src/controllers/user/otpStatus.ts
+++ b/packages/api/src/controllers/user/otpStatus.ts
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { z } from "zod/v4";
import { genericErrors } from "../../http/openapi-helpers.ts";
import { getOtpStatusModel } from "../../models/otp.ts";
-import { getUserOrganizations } from "../../models/rbac.ts";
+import { isUserOtpRequired } from "../../models/rbac.ts";
import { requireSession } from "../../services/sessions.ts";
import type { Context, ControllerSchema } from "../../types.ts";
import { withAudit } from "../../utils/auditWrapper.ts";
@@ -16,9 +16,7 @@ export const getOtpStatus = withAudit({ eventType: "OTP_STATUS", resourceType: "
): Promise {
const session = await requireSession(context, request, false);
const status = await getOtpStatusModel(context, "user", session.sub as string);
- const organizations = await getUserOrganizations(context, session.sub as string);
- const activeMemberships = organizations.filter((membership) => membership.status === "active");
- const required = activeMemberships.some((membership) => membership.forceOtp);
+ const required = await isUserOtpRequired(context, session.sub as string);
sendJson(response, 200, {
enabled: status.enabled,
pending: status.pending,
diff --git a/packages/api/src/controllers/user/session.test.ts b/packages/api/src/controllers/user/session.test.ts
index 5efa6ea6..4139db75 100644
--- a/packages/api/src/controllers/user/session.test.ts
+++ b/packages/api/src/controllers/user/session.test.ts
@@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { test } from "node:test";
+import { eq } from "drizzle-orm";
import { createPglite } from "../../db/pglite.ts";
import { organizationMembers, organizations, sessions, users } from "../../db/schema.ts";
import { ForbiddenError, InvalidRequestError } from "../../errors.ts";
@@ -231,6 +232,43 @@ test("getSession returns current organization context", async () => {
}
});
+test("getSession reports current forced OTP policy", async () => {
+ const { context, cleanup } = await createDatabaseContext();
+ try {
+ const organization = await createUserOrganization(
+ context,
+ "user-session-otp",
+ "session-otp-org"
+ );
+ await context.db
+ .update(organizations)
+ .set({ forceOtp: true })
+ .where(eq(organizations.id, organization.id));
+ await context.db.insert(organizationMembers).values({
+ organizationId: organization.id,
+ userSub: "user-session-otp",
+ status: "active",
+ });
+ await createUserSession(context, "session-otp-id", {
+ sub: "user-session-otp",
+ email: "user-session-otp@example.com",
+ name: "user-session-otp",
+ otpRequired: false,
+ otpVerified: false,
+ });
+ const request = createRequest({ sessionId: "session-otp-id" });
+ const response = createResponse();
+
+ await getSessionController(context, request, response);
+
+ assert.equal(response.statusCode, 200);
+ assert.equal((response.json as { otpRequired?: boolean }).otpRequired, true);
+ assert.equal((response.json as { otpVerified?: boolean }).otpVerified, false);
+ } finally {
+ await cleanup();
+ }
+});
+
test("postSessionOrganization updates session organization and returns validated redirect", async () => {
const { context, cleanup } = await createDatabaseContext();
try {
@@ -283,6 +321,47 @@ test("postSessionOrganization updates session organization and returns validated
const session = await context.db.query.sessions.findFirst();
assert.equal((session?.data as { organizationId?: string }).organizationId, organization.id);
assert.equal((session?.data as { organizationSlug?: string }).organizationSlug, "switch-org");
+ assert.equal((session?.data as { otpRequired?: boolean }).otpRequired, false);
+ assert.equal((session?.data as { otpVerified?: boolean }).otpVerified, false);
+ } finally {
+ await cleanup();
+ }
+});
+
+test("postSessionOrganization marks forced OTP organization in session", async () => {
+ const { context, cleanup } = await createDatabaseContext();
+ try {
+ const organization = await createUserOrganization(context, "user-switch-otp", "switch-otp-org");
+ await context.db
+ .update(organizations)
+ .set({ forceOtp: true })
+ .where(eq(organizations.id, organization.id));
+ await context.db.insert(organizationMembers).values({
+ organizationId: organization.id,
+ userSub: "user-switch-otp",
+ status: "active",
+ });
+ await createUserSession(context, "switch-otp-session-id", {
+ sub: "user-switch-otp",
+ email: "user-switch-otp@example.com",
+ otpRequired: false,
+ otpVerified: false,
+ });
+
+ const request = createRequest({
+ method: "POST",
+ url: "/session/organization",
+ sessionId: "switch-otp-session-id",
+ body: JSON.stringify({ organization_id: organization.id }),
+ });
+ const response = createResponse();
+
+ await postSessionOrganization(context, request, response);
+
+ assert.equal(response.statusCode, 200);
+ const session = await context.db.query.sessions.findFirst();
+ assert.equal((session?.data as { otpRequired?: boolean }).otpRequired, true);
+ assert.equal((session?.data as { otpVerified?: boolean }).otpVerified, false);
} finally {
await cleanup();
}
diff --git a/packages/api/src/controllers/user/session.ts b/packages/api/src/controllers/user/session.ts
index 65da985b..7e8757e2 100644
--- a/packages/api/src/controllers/user/session.ts
+++ b/packages/api/src/controllers/user/session.ts
@@ -4,6 +4,7 @@ import { ForbiddenError, InvalidRequestError, UnauthorizedError } from "../../er
import { genericErrors } from "../../http/openapi-helpers.ts";
import { getClient } from "../../models/clients.ts";
import { getOrganizationForUser } from "../../models/organizations.ts";
+import { isUserOtpRequired } from "../../models/rbac.ts";
import { getUserBySub } from "../../models/users.ts";
import { getClientIp, logAuditEvent } from "../../services/audit.ts";
import {
@@ -106,6 +107,7 @@ export async function getSession(
const user = sessionData.sub ? await getUserBySub(context, sessionData.sub) : null;
const resetRequired = !!user?.passwordResetRequired;
+ const otpRequired = await isUserOtpRequired(context, sessionData.sub);
const sessionInfo = {
sub: sessionData.sub,
@@ -118,7 +120,7 @@ export async function getSession(
signInEmail: user?.opaqueLoginIdentity || user?.email || sessionData.email || null,
authenticated: true,
passwordResetRequired: resetRequired,
- otpRequired: !!sessionData.otpRequired,
+ otpRequired,
otpVerified: !!sessionData.otpVerified,
keyState:
sessionData.keyState === "unlocked" || sessionData.keyState === "setup_required"
@@ -180,6 +182,8 @@ export async function postSessionOrganization(
...sessionData,
organizationId: organization.organizationId,
organizationSlug: organization.slug || undefined,
+ otpRequired: organization.forceOtp,
+ otpVerified: organization.forceOtp ? sessionData.otpVerified === true : false,
};
await updateSession(context, sessionId, nextSessionData);
@@ -249,6 +253,7 @@ export const schema = {
path: "/session",
tags: ["Auth"],
summary: "Get user session",
+ description: "Returns the current user session and selected organization context.",
responses: {
200: { description: "OK", content: { "application/json": { schema: Resp } } },
...genericErrors,
@@ -260,6 +265,8 @@ export const organizationSchema = {
path: "/session/organization",
tags: ["Auth"],
summary: "Set current user session organization",
+ description:
+ "Same-origin CSRF-protected endpoint for hosted DarkAuth UI to update the current session organization after active membership validation.",
body: {
description: "",
required: true,
diff --git a/packages/api/src/controllers/user/token.ts b/packages/api/src/controllers/user/token.ts
index e5db7479..a5c6c67d 100644
--- a/packages/api/src/controllers/user/token.ts
+++ b/packages/api/src/controllers/user/token.ts
@@ -9,6 +9,7 @@ import { signJWT } from "../../services/jwks.ts";
import {
clearRefreshTokenCookie,
createSession,
+ getActorFromRefreshToken,
getActorFromSessionId,
getRefreshTokenFromCookie,
getRefreshTokenSessionData,
@@ -299,6 +300,16 @@ export const postToken = withRateLimit("token")(
providedClientId,
!!tokenRequest.refresh_token
);
+ const { getUserOrgAccess, isUserOtpRequired, resolveAuthorizationOrganizationContext } =
+ await import("../../models/rbac.ts");
+ const refreshActor = await getActorFromRefreshToken(context, refreshToken);
+ const existingUserSessionData = existingSessionData as SessionData | null;
+ if (existingUserSessionData && refreshActor?.userSub) {
+ const existingOtpRequired = await isUserOtpRequired(context, refreshActor.userSub);
+ if (existingOtpRequired && existingUserSessionData?.otpVerified !== true) {
+ throw new InvalidGrantError("OTP verification required");
+ }
+ }
const rotated = await refreshSessionWithToken(context, refreshToken);
if (!rotated) {
if (!tokenRequest.refresh_token) {
@@ -310,6 +321,10 @@ export const postToken = withRateLimit("token")(
const sessionActor = await getActorFromSessionId(context, rotated.sessionId);
if (!sessionData || !sessionActor?.userSub)
throw new InvalidGrantError("Invalid or expired refresh token");
+ const otpRequired = await isUserOtpRequired(context, sessionActor.userSub);
+ if (otpRequired && (sessionData as SessionData).otpVerified !== true) {
+ throw new InvalidGrantError("OTP verification required");
+ }
const { getUserBySub } = await import("../../models/users.ts");
const user = await getUserBySub(context, sessionActor.userSub);
if (!user) throw new InvalidGrantError("User not found");
@@ -320,9 +335,6 @@ export const postToken = withRateLimit("token")(
);
if (!client) throw new UnauthorizedClientError("Unknown client");
- const { getUserOrgAccess, resolveAuthorizationOrganizationContext } = await import(
- "../../models/rbac.ts"
- );
const sessionOrganizationId =
typeof (sessionData as SessionData).organizationId === "string"
? (sessionData as SessionData).organizationId
@@ -351,6 +363,12 @@ export const postToken = withRateLimit("token")(
...(sessionData as SessionData),
organizationId,
organizationSlug: organizationSlug || undefined,
+ otpRequired,
+ });
+ } else if ((sessionData as SessionData).otpRequired !== otpRequired) {
+ await updateSession(context, rotated.sessionId, {
+ ...(sessionData as SessionData),
+ otpRequired,
});
}
const now = Math.floor(Date.now() / 1000);
@@ -779,6 +797,8 @@ export const schema = {
path: "/token",
tags: ["Auth"],
summary: "Token endpoint",
+ description:
+ "Refresh-token grants mint tokens for the current session organization. If no valid session organization exists for a multi-organization user, the response uses ORG_CONTEXT_REQUIRED.",
body: {
description: "",
required: true,
diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts
index 76505af0..d149954a 100644
--- a/packages/api/src/db/schema.ts
+++ b/packages/api/src/db/schema.ts
@@ -1,4 +1,4 @@
-import { relations } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import {
bigint,
boolean,
@@ -52,6 +52,10 @@ export const federationAccountLinkingPolicyEnum = pgEnum("federation_account_lin
"email_verified",
"email",
]);
+export const federationDomainVerificationStatusEnum = pgEnum(
+ "federation_domain_verification_status",
+ ["pending", "verified", "failed"]
+);
export const settings = pgTable("settings", {
key: text("key").primaryKey(),
@@ -118,6 +122,7 @@ export const federationConnections = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
type: federationConnectionTypeEnum("type").default("oidc").notNull(),
+ protocol: text("protocol").default("oidc").notNull(),
name: text("name").notNull(),
issuer: text("issuer").notNull(),
clientId: text("client_id").notNull(),
@@ -132,6 +137,16 @@ export const federationConnections = pgTable(
accountLinkingPolicy: federationAccountLinkingPolicyEnum("account_linking_policy")
.default("email_verified")
.notNull(),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organizations.id, { onDelete: "cascade" }),
+ jitProvisioning: boolean("jit_provisioning").default(true).notNull(),
+ membershipOnAuthentication: boolean("membership_on_authentication").default(true).notNull(),
+ requireScimPreProvisioning: boolean("require_scim_pre_provisioning").default(false).notNull(),
+ requirePasswordForZk: boolean("require_password_for_zk").default(false).notNull(),
+ allowPasskeyPrf: boolean("allow_passkey_prf").default(true).notNull(),
+ allowTrustedDeviceApproval: boolean("allow_trusted_device_approval").default(true).notNull(),
+ allowNonZkKeySetupBypass: boolean("allow_non_zk_key_setup_bypass").default(false).notNull(),
domains: text("domains").array().default([]).notNull(),
enabled: boolean("enabled").default(true).notNull(),
metadata: jsonb("metadata").default({}).notNull(),
@@ -143,10 +158,50 @@ export const federationConnections = pgTable(
table.issuer,
table.clientId
),
+ organizationIdIdx: index("federation_connections_organization_id_idx").on(table.organizationId),
enabledIdx: index("federation_connections_enabled_idx").on(table.enabled),
})
);
+export const federationConnectionDomains = pgTable(
+ "federation_connection_domains",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ connectionId: uuid("connection_id")
+ .notNull()
+ .references(() => federationConnections.id, { onDelete: "cascade" }),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organizations.id, { onDelete: "cascade" }),
+ domain: text("domain").notNull(),
+ verificationStatus: federationDomainVerificationStatusEnum("verification_status")
+ .default("pending")
+ .notNull(),
+ verificationTokenHash: text("verification_token_hash"),
+ verifiedAt: timestamp("verified_at"),
+ lastCheckedAt: timestamp("last_checked_at"),
+ enabled: boolean("enabled").default(true).notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => ({
+ connectionDomainIdx: uniqueIndex("federation_connection_domains_connection_domain_idx").on(
+ table.connectionId,
+ table.domain
+ ),
+ connectionIdIdx: index("federation_connection_domains_connection_id_idx").on(
+ table.connectionId
+ ),
+ organizationIdIdx: index("federation_connection_domains_organization_id_idx").on(
+ table.organizationId
+ ),
+ domainIdx: index("federation_connection_domains_domain_idx").on(table.domain),
+ verifiedDomainIdx: uniqueIndex("federation_connection_domains_verified_unique_idx")
+ .on(table.domain)
+ .where(sql`${table.enabled} = true AND ${table.verificationStatus} = 'verified'`),
+ })
+);
+
export const users = pgTable(
"users",
{
@@ -203,6 +258,10 @@ export const federationOidcStates = pgTable(
connectionId: uuid("connection_id")
.notNull()
.references(() => federationConnections.id, { onDelete: "cascade" }),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organizations.id, { onDelete: "cascade" }),
+ clientId: text("client_id").references(() => clients.clientId, { onDelete: "set null" }),
nonceHash: text("nonce_hash").notNull(),
codeVerifierHash: text("code_verifier_hash"),
returnTo: text("return_to"),
@@ -212,6 +271,7 @@ export const federationOidcStates = pgTable(
},
(table) => ({
connectionIdIdx: index("federation_oidc_states_connection_id_idx").on(table.connectionId),
+ organizationIdIdx: index("federation_oidc_states_organization_id_idx").on(table.organizationId),
expiresAtIdx: index("federation_oidc_states_expires_at_idx").on(table.expiresAt),
})
);
@@ -593,13 +653,40 @@ export const adminOpaqueRecords = pgTable("admin_opaque_records", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
+export const scimConnections = pgTable(
+ "scim_connections",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organizations.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ enabled: boolean("enabled").default(true).notNull(),
+ deprovisionAction: text("deprovision_action").default("suspend_membership").notNull(),
+ deleteUserSafety: text("delete_user_safety").default("fail_closed").notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => ({
+ organizationIdIdx: index("scim_connections_organization_id_idx").on(table.organizationId),
+ enabledIdx: index("scim_connections_enabled_idx").on(table.enabled),
+ })
+);
+
export const scimBearerTokens = pgTable(
"scim_bearer_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
+ connectionId: uuid("connection_id").references(() => scimConnections.id, {
+ onDelete: "cascade",
+ }),
+ organizationId: uuid("organization_id").references(() => organizations.id, {
+ onDelete: "cascade",
+ }),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull(),
tokenPrefix: text("token_prefix").notNull(),
+ scopes: text("scopes").array().default(["scim:read", "scim:write"]).notNull(),
createdByAdminId: uuid("created_by_admin_id").references(() => adminUsers.id, {
onDelete: "set null",
}),
@@ -610,6 +697,8 @@ export const scimBearerTokens = pgTable(
},
(table) => ({
tokenHashIdx: uniqueIndex("scim_bearer_tokens_token_hash_idx").on(table.tokenHash),
+ connectionIdIdx: index("scim_bearer_tokens_connection_id_idx").on(table.connectionId),
+ organizationIdIdx: index("scim_bearer_tokens_organization_id_idx").on(table.organizationId),
activeIdx: index("scim_bearer_tokens_active_idx").on(table.revokedAt, table.expiresAt),
})
);
@@ -617,9 +706,19 @@ export const scimBearerTokens = pgTable(
export const scimUsers = pgTable(
"scim_users",
{
+ id: uuid("id").primaryKey().defaultRandom(),
userSub: text("user_sub")
- .primaryKey()
+ .notNull()
.references(() => users.sub, { onDelete: "cascade" }),
+ connectionId: uuid("connection_id").references(() => scimConnections.id, {
+ onDelete: "cascade",
+ }),
+ organizationId: uuid("organization_id").references(() => organizations.id, {
+ onDelete: "cascade",
+ }),
+ organizationMemberId: uuid("organization_member_id").references(() => organizationMembers.id, {
+ onDelete: "set null",
+ }),
externalId: text("external_id"),
userName: text("user_name").notNull(),
displayName: text("display_name"),
@@ -629,8 +728,21 @@ export const scimUsers = pgTable(
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => ({
- externalIdIdx: uniqueIndex("scim_users_external_id_idx").on(table.externalId),
- userNameIdx: uniqueIndex("scim_users_user_name_idx").on(table.userName),
+ connectionExternalIdIdx: uniqueIndex("scim_users_connection_external_id_idx").on(
+ table.connectionId,
+ table.externalId
+ ),
+ connectionUserNameIdx: uniqueIndex("scim_users_connection_user_name_idx").on(
+ table.connectionId,
+ table.userName
+ ),
+ connectionUserSubIdx: uniqueIndex("scim_users_connection_user_sub_idx").on(
+ table.connectionId,
+ table.userSub
+ ),
+ connectionIdIdx: index("scim_users_connection_id_idx").on(table.connectionId),
+ organizationIdIdx: index("scim_users_organization_id_idx").on(table.organizationId),
+ userSubIdx: index("scim_users_user_sub_idx").on(table.userSub),
activeIdx: index("scim_users_active_idx").on(table.active),
})
);
@@ -639,6 +751,12 @@ export const scimGroups = pgTable(
"scim_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
+ connectionId: uuid("connection_id").references(() => scimConnections.id, {
+ onDelete: "cascade",
+ }),
+ organizationId: uuid("organization_id").references(() => organizations.id, {
+ onDelete: "cascade",
+ }),
externalId: text("external_id"),
displayName: text("display_name").notNull(),
raw: jsonb("raw").default({}).notNull(),
@@ -646,8 +764,16 @@ export const scimGroups = pgTable(
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => ({
- externalIdIdx: uniqueIndex("scim_groups_external_id_idx").on(table.externalId),
- displayNameIdx: uniqueIndex("scim_groups_display_name_idx").on(table.displayName),
+ connectionExternalIdIdx: uniqueIndex("scim_groups_connection_external_id_idx").on(
+ table.connectionId,
+ table.externalId
+ ),
+ connectionDisplayNameIdx: uniqueIndex("scim_groups_connection_display_name_idx").on(
+ table.connectionId,
+ table.displayName
+ ),
+ connectionIdIdx: index("scim_groups_connection_id_idx").on(table.connectionId),
+ organizationIdIdx: index("scim_groups_organization_id_idx").on(table.organizationId),
})
);
@@ -695,6 +821,9 @@ export const organizationMembers = pgTable(
.notNull()
.references(() => users.sub, { onDelete: "cascade" }),
status: organizationStatusEnum("status").default("active").notNull(),
+ scimConnectionId: uuid("scim_connection_id").references(() => scimConnections.id, {
+ onDelete: "set null",
+ }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
@@ -705,6 +834,9 @@ export const organizationMembers = pgTable(
),
userSubIdx: index("organization_members_user_sub_idx").on(table.userSub),
organizationIdIdx: index("organization_members_organization_id_idx").on(table.organizationId),
+ scimConnectionIdIdx: index("organization_members_scim_connection_id_idx").on(
+ table.scimConnectionId
+ ),
})
);
@@ -714,6 +846,9 @@ export const roles = pgTable("roles", {
name: text("name").notNull(),
description: text("description"),
system: boolean("system").default(false).notNull(),
+ assignable: boolean("assignable").default(false).notNull(),
+ defaultMember: boolean("default_member").default(false).notNull(),
+ defaultCreator: boolean("default_creator").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
@@ -743,12 +878,20 @@ export const organizationMemberRoles = pgTable(
roleId: uuid("role_id")
.notNull()
.references(() => roles.id, { onDelete: "cascade" }),
+ scimConnectionId: uuid("scim_connection_id").references(() => scimConnections.id, {
+ onDelete: "set null",
+ }),
+ scimGroupId: uuid("scim_group_id").references(() => scimGroups.id, { onDelete: "set null" }),
},
(table) => ({
pk: primaryKey({ columns: [table.organizationMemberId, table.roleId] }),
organizationMemberIdIdx: index("organization_member_roles_member_id_idx").on(
table.organizationMemberId
),
+ scimConnectionIdIdx: index("organization_member_roles_scim_connection_id_idx").on(
+ table.scimConnectionId
+ ),
+ scimGroupIdIdx: index("organization_member_roles_scim_group_id_idx").on(table.scimGroupId),
})
);
@@ -864,8 +1007,23 @@ export const clientsRelations = relations(clients, ({ many }) => ({
export const federationConnectionsRelations = relations(federationConnections, ({ many }) => ({
identities: many(federationIdentities),
oidcStates: many(federationOidcStates),
+ domains: many(federationConnectionDomains),
}));
+export const federationConnectionDomainsRelations = relations(
+ federationConnectionDomains,
+ ({ one }) => ({
+ connection: one(federationConnections, {
+ fields: [federationConnectionDomains.connectionId],
+ references: [federationConnections.id],
+ }),
+ organization: one(organizations, {
+ fields: [federationConnectionDomains.organizationId],
+ references: [organizations.id],
+ }),
+ })
+);
+
export const federationIdentitiesRelations = relations(federationIdentities, ({ one }) => ({
connection: one(federationConnections, {
fields: [federationIdentities.connectionId],
@@ -882,6 +1040,10 @@ export const federationOidcStatesRelations = relations(federationOidcStates, ({
fields: [federationOidcStates.connectionId],
references: [federationConnections.id],
}),
+ organization: one(organizations, {
+ fields: [federationOidcStates.organizationId],
+ references: [organizations.id],
+ }),
}));
export const accountKeysRelations = relations(accountKeys, ({ one, many }) => ({
@@ -968,8 +1130,11 @@ export const organizationsRelations = relations(organizations, ({ one, many }) =
}),
members: many(organizationMembers),
invites: many(organizationInvites),
+ federationConnections: many(federationConnections),
+ federationConnectionDomains: many(federationConnectionDomains),
authCodes: many(authCodes),
pendingAuth: many(pendingAuth),
+ scimConnections: many(scimConnections),
}));
export const organizationMembersRelations = relations(organizationMembers, ({ one, many }) => ({
@@ -1004,7 +1169,25 @@ export const adminOpaqueRecordsRelations = relations(adminOpaqueRecords, ({ one
}),
}));
+export const scimConnectionsRelations = relations(scimConnections, ({ one, many }) => ({
+ organization: one(organizations, {
+ fields: [scimConnections.organizationId],
+ references: [organizations.id],
+ }),
+ tokens: many(scimBearerTokens),
+ users: many(scimUsers),
+ groups: many(scimGroups),
+}));
+
export const scimBearerTokensRelations = relations(scimBearerTokens, ({ one }) => ({
+ connection: one(scimConnections, {
+ fields: [scimBearerTokens.connectionId],
+ references: [scimConnections.id],
+ }),
+ organization: one(organizations, {
+ fields: [scimBearerTokens.organizationId],
+ references: [organizations.id],
+ }),
createdByAdmin: one(adminUsers, {
fields: [scimBearerTokens.createdByAdminId],
references: [adminUsers.id],
@@ -1016,6 +1199,18 @@ export const scimUsersRelations = relations(scimUsers, ({ one, many }) => ({
fields: [scimUsers.userSub],
references: [users.sub],
}),
+ connection: one(scimConnections, {
+ fields: [scimUsers.connectionId],
+ references: [scimConnections.id],
+ }),
+ organization: one(organizations, {
+ fields: [scimUsers.organizationId],
+ references: [organizations.id],
+ }),
+ organizationMember: one(organizationMembers, {
+ fields: [scimUsers.organizationMemberId],
+ references: [organizationMembers.id],
+ }),
groups: many(scimGroupMembers),
}));
@@ -1100,6 +1295,11 @@ export const auditLogs = pgTable(
userId: text("user_id"),
adminId: uuid("admin_id"),
clientId: text("client_id"),
+ organizationId: uuid("organization_id").references(() => organizations.id, {
+ onDelete: "set null",
+ }),
+ enterpriseConnectionId: uuid("enterprise_connection_id"),
+ enterpriseConnectionType: text("enterprise_connection_type"),
ipAddress: text("ip_address").notNull(),
userAgent: text("user_agent"),
success: boolean("success").notNull(),
@@ -1118,6 +1318,7 @@ export const auditLogs = pgTable(
timestampIdx: index("audit_logs_timestamp_idx").on(table.timestamp),
userIdIdx: index("audit_logs_user_id_idx").on(table.userId),
adminIdIdx: index("audit_logs_admin_id_idx").on(table.adminId),
+ organizationIdIdx: index("audit_logs_organization_id_idx").on(table.organizationId),
eventTypeIdx: index("audit_logs_event_type_idx").on(table.eventType),
resourceIdx: index("audit_logs_resource_idx").on(table.resourceType, table.resourceId),
})
diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts
index 1b1f8cd5..7c876e8b 100644
--- a/packages/api/src/errors.ts
+++ b/packages/api/src/errors.ts
@@ -15,8 +15,8 @@ export class AppError extends Error {
export class ValidationError extends AppError {
details?: unknown;
- constructor(message: string, details?: unknown) {
- super(message, "VALIDATION_ERROR", 400);
+ constructor(message: string, details?: unknown, code = "VALIDATION_ERROR") {
+ super(message, code, 400);
this.details = details;
}
}
diff --git a/packages/api/src/http/openapi.ts b/packages/api/src/http/openapi.ts
index 329e0fbd..d970dabc 100644
--- a/packages/api/src/http/openapi.ts
+++ b/packages/api/src/http/openapi.ts
@@ -106,7 +106,11 @@ import { schema as userOpaqueRegisterFinishSchema } from "../controllers/user/op
import { schema as userOpaqueRegisterStartSchema } from "../controllers/user/opaqueRegisterStart.ts";
import {
createOrganizationSchema as userCreateOrganizationSchema,
+ organizationAssignableRolesSchema as userOrganizationAssignableRolesSchema,
+ organizationDeleteSchema as userOrganizationDeleteSchema,
organizationInvitesSchema as userOrganizationInvitesSchema,
+ organizationLeaveSchema as userOrganizationLeaveSchema,
+ organizationMemberDeleteSchema as userOrganizationMemberDeleteSchema,
organizationMemberRoleDeleteSchema as userOrganizationMemberRoleDeleteSchema,
organizationMemberRolesSchema as userOrganizationMemberRolesSchema,
organizationMembersSchema as userOrganizationMembersSchema,
@@ -140,7 +144,10 @@ import {
postRecoveryKeyUseSchema as userRecoveryKeyUseSchema,
} from "../controllers/user/recoveryKeys.ts";
import { schema as userRevokeSchema } from "../controllers/user/revoke.ts";
-import { schema as userSessionSchema } from "../controllers/user/session.ts";
+import {
+ organizationSchema as userSessionOrganizationSchema,
+ schema as userSessionSchema,
+} from "../controllers/user/session.ts";
import { schema as userTokenSchema } from "../controllers/user/token.ts";
import {
postDeviceApprovalApproveSchema as userDeviceApprovalApproveSchema,
@@ -259,6 +266,7 @@ const documentedSchemas: ControllerSchema[] = [
userAuthorizeSchema,
userAuthorizeFinalizeSchema,
userSessionSchema,
+ userSessionOrganizationSchema,
userLogoutSchema,
userTokenSchema,
userUserinfoSchema,
@@ -301,9 +309,13 @@ const documentedSchemas: ControllerSchema[] = [
userCreateOrganizationSchema,
userOrganizationSchema,
userOrganizationMembersSchema,
+ userOrganizationAssignableRolesSchema,
userOrganizationInvitesSchema,
userOrganizationMemberRolesSchema,
userOrganizationMemberRoleDeleteSchema,
+ userOrganizationMemberDeleteSchema,
+ userOrganizationLeaveSchema,
+ userOrganizationDeleteSchema,
userPasswordChangeStartSchema,
userPasswordChangeFinishSchema,
userPasswordChangeVerifyStartSchema,
diff --git a/packages/api/src/http/routers/adminRouter.ts b/packages/api/src/http/routers/adminRouter.ts
index 4954a5ba..7220d146 100644
--- a/packages/api/src/http/routers/adminRouter.ts
+++ b/packages/api/src/http/routers/adminRouter.ts
@@ -25,12 +25,16 @@ import {
import { postAdminEmailTest } from "../../controllers/admin/emailTest.ts";
import {
deleteFederationConnectionController,
+ deleteFederationConnectionDomainController,
getFederationConnectionController,
+ getFederationConnectionDomains,
getFederationConnections,
getFederationDomainRoute,
getFederationOidcDiscovery,
postFederationConnection,
+ postFederationConnectionDomain,
putFederationConnection,
+ verifyFederationConnectionDomainController,
} from "../../controllers/admin/federationConnections.ts";
import { getJwks } from "../../controllers/admin/jwks.ts";
import { rotateJwks } from "../../controllers/admin/jwksRotate.ts";
@@ -449,6 +453,45 @@ export function createAdminRouter(context: Context) {
return await getFederationOidcDiscovery(context, request, response);
}
+ const federationDomainVerifyMatch = pathname.match(
+ /^\/admin\/federation\/connections\/([^/]+)\/domains\/([^/]+)\/verify$/
+ );
+ if (federationDomainVerifyMatch && method === "POST") {
+ return await verifyFederationConnectionDomainController(
+ context,
+ request,
+ response,
+ federationDomainVerifyMatch[1] as string,
+ federationDomainVerifyMatch[2] as string
+ );
+ }
+
+ const federationDomainItemMatch = pathname.match(
+ /^\/admin\/federation\/connections\/([^/]+)\/domains\/([^/]+)$/
+ );
+ if (federationDomainItemMatch && method === "DELETE") {
+ return await deleteFederationConnectionDomainController(
+ context,
+ request,
+ response,
+ federationDomainItemMatch[1] as string,
+ federationDomainItemMatch[2] as string
+ );
+ }
+
+ const federationDomainsMatch = pathname.match(
+ /^\/admin\/federation\/connections\/([^/]+)\/domains$/
+ );
+ if (federationDomainsMatch) {
+ const connectionId = federationDomainsMatch[1] as string;
+ if (method === "GET") {
+ return await getFederationConnectionDomains(context, request, response, connectionId);
+ }
+ if (method === "POST") {
+ return await postFederationConnectionDomain(context, request, response, connectionId);
+ }
+ }
+
const federationConnectionMatch = pathname.match(
/^\/admin\/federation\/connections\/([^/]+)$/
);
diff --git a/packages/api/src/http/routers/userRouter.test.ts b/packages/api/src/http/routers/userRouter.test.ts
index a60eeccd..91e92432 100644
--- a/packages/api/src/http/routers/userRouter.test.ts
+++ b/packages/api/src/http/routers/userRouter.test.ts
@@ -47,11 +47,17 @@ function createMockResponse() {
} as Partial;
}
-function createRequest(url: string) {
+function createRequest(
+ url: string,
+ options: {
+ method?: string;
+ headers?: Record;
+ } = {}
+) {
return {
- method: "GET",
+ method: options.method || "GET",
url,
- headers: { host: "localhost" },
+ headers: { host: "localhost", ...options.headers },
socket: { remoteAddress: "127.0.0.1" },
} as Partial;
}
@@ -178,3 +184,46 @@ test("user router exposes page background CSS variables for branded user pages",
);
assert.match(response.body, /body\{background:var\(--da-page-bg\)/);
});
+
+test("user router blocks cross-origin session organization updates", async () => {
+ const context = {
+ logger: createLogger(),
+ };
+
+ const router = createUserRouter(context as never);
+ const request = createRequest("/session/organization", {
+ method: "POST",
+ headers: {
+ origin: "https://evil.example",
+ cookie: "__Host-DarkAuth-User=session-id; __Host-DarkAuth-User-Csrf=csrf-token",
+ "x-csrf-token": "csrf-token",
+ },
+ });
+ const response = createMockResponse();
+
+ await router(request as IncomingMessage, response as ServerResponse);
+
+ assert.equal(response.statusCode, 403);
+ assert.equal(response.json.error, "Cross-site request blocked");
+});
+
+test("user router requires CSRF token for session organization updates", async () => {
+ const context = {
+ logger: createLogger(),
+ };
+
+ const router = createUserRouter(context as never);
+ const request = createRequest("/session/organization", {
+ method: "POST",
+ headers: {
+ origin: "http://localhost",
+ cookie: "__Host-DarkAuth-User=session-id; __Host-DarkAuth-User-Csrf=csrf-token",
+ },
+ });
+ const response = createMockResponse();
+
+ await router(request as IncomingMessage, response as ServerResponse);
+
+ assert.equal(response.statusCode, 403);
+ assert.equal(response.json.error, "Missing or invalid CSRF token");
+});
diff --git a/packages/api/src/http/routers/userRouter.ts b/packages/api/src/http/routers/userRouter.ts
index f4180af6..46323a08 100644
--- a/packages/api/src/http/routers/userRouter.ts
+++ b/packages/api/src/http/routers/userRouter.ts
@@ -7,6 +7,25 @@ import { postEmailVerificationResend } from "../../controllers/user/emailVerific
import { postEmailVerificationVerify } from "../../controllers/user/emailVerificationVerify.ts";
import { getEncPublicJwk } from "../../controllers/user/encPublicGet.ts";
import { putEncPublicJwk } from "../../controllers/user/encPublicPut.ts";
+import {
+ deleteOrganizationFederationConnection,
+ deleteOrganizationFederationDomain,
+ deleteOrganizationScimConnection,
+ deleteOrganizationScimToken,
+ getOrganizationFederationConnection,
+ getOrganizationFederationConnections,
+ getOrganizationFederationDomains,
+ getOrganizationScimConnection,
+ getOrganizationScimConnections,
+ getOrganizationScimTokens,
+ postOrganizationFederationConnection,
+ postOrganizationFederationDomain,
+ postOrganizationFederationDomainVerify,
+ postOrganizationScimConnection,
+ postOrganizationScimToken,
+ putOrganizationFederationConnection,
+ putOrganizationScimConnection,
+} from "../../controllers/user/enterpriseConnections.ts";
import { getFederationIdentities } from "../../controllers/user/federationIdentities.ts";
import {
getFederationCallback,
@@ -29,11 +48,15 @@ import { postOpaqueLoginStart } from "../../controllers/user/opaqueLoginStart.ts
import { postOpaqueRegisterFinish } from "../../controllers/user/opaqueRegisterFinish.ts";
import { postOpaqueRegisterStart } from "../../controllers/user/opaqueRegisterStart.ts";
import {
+ deleteOrganizationController,
+ deleteOrganizationMember,
deleteOrganizationMemberRole,
getOrganization,
+ getOrganizationAssignableRoles,
getOrganizationMembers,
getOrganizations,
postOrganizationInvites,
+ postOrganizationLeave,
postOrganizationMemberRoles,
postOrganizations,
} from "../../controllers/user/organizations.ts";
@@ -846,6 +869,19 @@ export function createUserRouter(context: Context) {
if (method === "GET" && orgMatch) {
return await getOrganization(context, request, response, orgMatch[1] as string);
}
+ if (method === "DELETE" && orgMatch) {
+ return await deleteOrganizationController(
+ context,
+ request,
+ response,
+ orgMatch[1] as string
+ );
+ }
+
+ const orgLeaveMatch = pathname.match(/^\/organizations\/([^/]+)\/leave$/);
+ if (method === "POST" && orgLeaveMatch) {
+ return await postOrganizationLeave(context, request, response, orgLeaveMatch[1] as string);
+ }
const orgMembersMatch = pathname.match(/^\/organizations\/([^/]+)\/members$/);
if (method === "GET" && orgMembersMatch) {
@@ -857,6 +893,18 @@ export function createUserRouter(context: Context) {
);
}
+ const orgAssignableRolesMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/roles\/assignable$/
+ );
+ if (method === "GET" && orgAssignableRolesMatch) {
+ return await getOrganizationAssignableRoles(
+ context,
+ request,
+ response,
+ orgAssignableRolesMatch[1] as string
+ );
+ }
+
const orgInvitesMatch = pathname.match(/^\/organizations\/([^/]+)\/invites$/);
if (method === "POST" && orgInvitesMatch) {
return await postOrganizationInvites(
@@ -880,6 +928,17 @@ export function createUserRouter(context: Context) {
);
}
+ const orgMemberDeleteMatch = pathname.match(/^\/organizations\/([^/]+)\/members\/([^/]+)$/);
+ if (method === "DELETE" && orgMemberDeleteMatch) {
+ return await deleteOrganizationMember(
+ context,
+ request,
+ response,
+ orgMemberDeleteMatch[1] as string,
+ orgMemberDeleteMatch[2] as string
+ );
+ }
+
const orgMemberRoleDeleteMatch = pathname.match(
/^\/organizations\/([^/]+)\/members\/([^/]+)\/roles\/([^/]+)$/
);
@@ -894,6 +953,184 @@ export function createUserRouter(context: Context) {
);
}
+ const orgFederationConnectionsMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/federation\/connections$/
+ );
+ if (orgFederationConnectionsMatch) {
+ const orgId = orgFederationConnectionsMatch[1] as string;
+ if (method === "GET") {
+ return await getOrganizationFederationConnections(context, request, response, orgId);
+ }
+ if (method === "POST") {
+ return await postOrganizationFederationConnection(context, request, response, orgId);
+ }
+ }
+
+ const orgFederationDomainVerifyMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/federation\/connections\/([^/]+)\/domains\/([^/]+)\/verify$/
+ );
+ if (orgFederationDomainVerifyMatch && method === "POST") {
+ return await postOrganizationFederationDomainVerify(
+ context,
+ request,
+ response,
+ orgFederationDomainVerifyMatch[1] as string,
+ orgFederationDomainVerifyMatch[2] as string,
+ orgFederationDomainVerifyMatch[3] as string
+ );
+ }
+
+ const orgFederationDomainItemMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/federation\/connections\/([^/]+)\/domains\/([^/]+)$/
+ );
+ if (orgFederationDomainItemMatch && method === "DELETE") {
+ return await deleteOrganizationFederationDomain(
+ context,
+ request,
+ response,
+ orgFederationDomainItemMatch[1] as string,
+ orgFederationDomainItemMatch[2] as string,
+ orgFederationDomainItemMatch[3] as string
+ );
+ }
+
+ const orgFederationDomainsMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/federation\/connections\/([^/]+)\/domains$/
+ );
+ if (orgFederationDomainsMatch) {
+ const orgId = orgFederationDomainsMatch[1] as string;
+ const connectionId = orgFederationDomainsMatch[2] as string;
+ if (method === "GET") {
+ return await getOrganizationFederationDomains(
+ context,
+ request,
+ response,
+ orgId,
+ connectionId
+ );
+ }
+ if (method === "POST") {
+ return await postOrganizationFederationDomain(
+ context,
+ request,
+ response,
+ orgId,
+ connectionId
+ );
+ }
+ }
+
+ const orgFederationConnectionItemMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/federation\/connections\/([^/]+)$/
+ );
+ if (orgFederationConnectionItemMatch) {
+ const orgId = orgFederationConnectionItemMatch[1] as string;
+ const connectionId = orgFederationConnectionItemMatch[2] as string;
+ if (method === "GET") {
+ return await getOrganizationFederationConnection(
+ context,
+ request,
+ response,
+ orgId,
+ connectionId
+ );
+ }
+ if (method === "PUT") {
+ return await putOrganizationFederationConnection(
+ context,
+ request,
+ response,
+ orgId,
+ connectionId
+ );
+ }
+ if (method === "DELETE") {
+ return await deleteOrganizationFederationConnection(
+ context,
+ request,
+ response,
+ orgId,
+ connectionId
+ );
+ }
+ }
+
+ const orgScimConnectionsMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/scim\/connections$/
+ );
+ if (orgScimConnectionsMatch) {
+ const orgId = orgScimConnectionsMatch[1] as string;
+ if (method === "GET") {
+ return await getOrganizationScimConnections(context, request, response, orgId);
+ }
+ if (method === "POST") {
+ return await postOrganizationScimConnection(context, request, response, orgId);
+ }
+ }
+
+ const orgScimTokenItemMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/scim\/connections\/([^/]+)\/tokens\/([^/]+)$/
+ );
+ if (orgScimTokenItemMatch && method === "DELETE") {
+ return await deleteOrganizationScimToken(
+ context,
+ request,
+ response,
+ orgScimTokenItemMatch[1] as string,
+ orgScimTokenItemMatch[2] as string,
+ orgScimTokenItemMatch[3] as string
+ );
+ }
+
+ const orgScimTokensMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/scim\/connections\/([^/]+)\/tokens$/
+ );
+ if (orgScimTokensMatch) {
+ const orgId = orgScimTokensMatch[1] as string;
+ const connectionId = orgScimTokensMatch[2] as string;
+ if (method === "GET") {
+ return await getOrganizationScimTokens(context, request, response, orgId, connectionId);
+ }
+ if (method === "POST") {
+ return await postOrganizationScimToken(context, request, response, orgId, connectionId);
+ }
+ }
+
+ const orgScimConnectionItemMatch = pathname.match(
+ /^\/organizations\/([^/]+)\/scim\/connections\/([^/]+)$/
+ );
+ if (orgScimConnectionItemMatch) {
+ const orgId = orgScimConnectionItemMatch[1] as string;
+ const connectionId = orgScimConnectionItemMatch[2] as string;
+ if (method === "GET") {
+ return await getOrganizationScimConnection(
+ context,
+ request,
+ response,
+ orgId,
+ connectionId
+ );
+ }
+ if (method === "PUT") {
+ return await putOrganizationScimConnection(
+ context,
+ request,
+ response,
+ orgId,
+ connectionId
+ );
+ }
+ if (method === "DELETE") {
+ return await deleteOrganizationScimConnection(
+ context,
+ request,
+ response,
+ orgId,
+ connectionId
+ );
+ }
+ }
+
if (method === "GET" && pathname === "/users") {
return await searchUserDirectory(context, request, response);
}
diff --git a/packages/api/src/models/authCodes.ts b/packages/api/src/models/authCodes.ts
index 6976c7ae..d0e0eee0 100644
--- a/packages/api/src/models/authCodes.ts
+++ b/packages/api/src/models/authCodes.ts
@@ -6,10 +6,14 @@ import type { Context } from "../types.ts";
export async function getAuthCode(context: Context, code: string) {
const authCode = await context.db.query.authCodes.findFirst({ where: eq(authCodes.code, code) });
if (!authCode) return null;
- const scimUser = await context.db.query.scimUsers.findFirst({
+ const scimRows = await context.db.query.scimUsers.findMany({
where: eq(scimUsers.userSub, authCode.userSub),
});
- if (scimUser && !scimUser.active) return null;
+ const scopedRow = authCode.organizationId
+ ? scimRows.find((row) => row.organizationId === authCode.organizationId)
+ : null;
+ if (scopedRow && !scopedRow.active) return null;
+ if (!scopedRow && scimRows.length > 0 && scimRows.every((row) => !row.active)) return null;
return authCode;
}
diff --git a/packages/api/src/models/federation.test.ts b/packages/api/src/models/federation.test.ts
index 960a791a..f4ef588c 100644
--- a/packages/api/src/models/federation.test.ts
+++ b/packages/api/src/models/federation.test.ts
@@ -3,18 +3,24 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { mock, test } from "node:test";
+import { eq } from "drizzle-orm";
import { createPglite } from "../db/pglite.ts";
-import { users } from "../db/schema.ts";
+import { organizationMembers, organizations, users } from "../db/schema.ts";
import type { Context } from "../types.ts";
import {
consumeOidcCallbackState,
createFederationConnection,
+ createFederationConnectionDomain,
createOidcCallbackState,
discoverOidcMetadata,
+ federationDomainRecordName,
+ federationDomainRecordValue,
findFederationConnectionForEmail,
listFederationConnections,
mapFederationClaims,
resolveFederatedUserForClaims,
+ runFederationDomainDnsVerification,
+ verifyFederationConnectionDomain,
} from "./federation.ts";
function createLogger() {
@@ -152,6 +158,152 @@ test("federation configuration rejects email-only account linking", async () =>
}
});
+test("federation routing uses verified domains and organization scope", async () => {
+ const { context, cleanup } = await createContext();
+ try {
+ const [organization] = await context.db
+ .insert(organizations)
+ .values({
+ id: "11111111-1111-4111-8111-111111111111",
+ slug: "acme",
+ name: "Acme",
+ })
+ .returning();
+ const connection = await createFederationConnection(context, {
+ organizationId: organization?.id,
+ name: "Acme IDP",
+ issuer: metadata.issuer,
+ clientId: "acme-client",
+ metadata,
+ });
+ await createFederationConnectionDomain(context, {
+ connectionId: connection.id,
+ domain: "acme.com",
+ });
+
+ const pendingRoute = await findFederationConnectionForEmail(context, "user@acme.com", {
+ organizationId: organization?.id,
+ });
+ await verifyFederationConnectionDomain(context, connection.id, "acme.com");
+ const scopedRoute = await findFederationConnectionForEmail(context, "user@acme.com", {
+ organizationId: organization?.id,
+ });
+ const defaultOrganization = await context.db.query.organizations.findFirst({
+ where: eq(organizations.slug, "default"),
+ });
+ const wrongOrgRoute = await findFederationConnectionForEmail(context, "user@acme.com", {
+ organizationId: defaultOrganization?.id,
+ });
+
+ assert.equal(pendingRoute, null);
+ assert.equal(scopedRoute?.id, connection.id);
+ assert.equal(wrongOrgRoute, null);
+ } finally {
+ await cleanup();
+ }
+});
+
+test("federation JIT creates membership in the connection organization only", async () => {
+ const { context, cleanup } = await createContext();
+ try {
+ const [organization] = await context.db
+ .insert(organizations)
+ .values({
+ id: "22222222-2222-4222-8222-222222222222",
+ slug: "jit-acme",
+ name: "JIT Acme",
+ })
+ .returning();
+ const connection = await createFederationConnection(context, {
+ organizationId: organization?.id,
+ name: "JIT Acme IDP",
+ issuer: metadata.issuer,
+ clientId: "jit-acme-client",
+ metadata,
+ domains: ["jit-acme.com"],
+ accountLinkingPolicy: "disabled",
+ jitProvisioning: true,
+ membershipOnAuthentication: true,
+ requireScimPreProvisioning: false,
+ });
+
+ const resolved = await resolveFederatedUserForClaims(context, connection.id, {
+ sub: "jit-external-sub",
+ email: "new@jit-acme.com",
+ email_verified: true,
+ name: "New Federated User",
+ });
+ const membershipRows = await context.db
+ .select()
+ .from(organizationMembers)
+ .where(eq(organizationMembers.userSub, resolved.userSub || ""));
+
+ assert.equal(resolved.linked, true);
+ assert.equal(resolved.created, true);
+ assert.equal(membershipRows.length, 1);
+ assert.equal(membershipRows[0]?.organizationId, organization?.id);
+ assert.equal(membershipRows[0]?.status, "active");
+ } finally {
+ await cleanup();
+ }
+});
+
+test("federation DNS TXT verification surfaces a record and verifies on match", async () => {
+ const { context, cleanup } = await createContext();
+ try {
+ const [organization] = await context.db
+ .insert(organizations)
+ .values({
+ id: "33333333-3333-4333-8333-333333333333",
+ slug: "dns-acme",
+ name: "DNS Acme",
+ })
+ .returning();
+ const connection = await createFederationConnection(context, {
+ organizationId: organization?.id,
+ name: "DNS Acme IDP",
+ issuer: metadata.issuer,
+ clientId: "dns-acme-client",
+ metadata,
+ });
+ const created = await createFederationConnectionDomain(context, {
+ connectionId: connection.id,
+ domain: "dns-acme.com",
+ });
+
+ assert.equal(created.verificationStatus, "pending");
+ assert.equal(created.recordName, federationDomainRecordName("dns-acme.com"));
+ assert.ok(created.verificationToken);
+ assert.equal(created.recordValue, federationDomainRecordValue(created.verificationToken || ""));
+
+ const failed = await runFederationDomainDnsVerification(
+ context,
+ connection.id,
+ created.id,
+ async () => [["some-other-value"]]
+ );
+ assert.equal(failed.status, "failed");
+ assert.equal(failed.domain.verificationStatus, "failed");
+ assert.ok(failed.domain.lastCheckedAt);
+
+ const verified = await runFederationDomainDnsVerification(
+ context,
+ connection.id,
+ created.id,
+ async () => [["unrelated"], [federationDomainRecordValue(created.verificationToken || "")]]
+ );
+ assert.equal(verified.status, "verified");
+ assert.equal(verified.domain.verificationStatus, "verified");
+
+ const route = await findFederationConnectionForEmail(context, "user@dns-acme.com", {
+ organizationId: organization?.id,
+ });
+ assert.equal(route?.id, connection.id);
+ } finally {
+ await cleanup();
+ }
+});
+
test("federation OIDC callback state is single use and bound to nonce", async () => {
const { context, cleanup } = await createContext();
try {
@@ -164,6 +316,8 @@ test("federation OIDC callback state is single use and bound to nonce", async ()
});
const state = await createOidcCallbackState(context, {
connectionId: connection.id,
+ organizationId: connection.organizationId,
+ clientId: "user",
nonce: "nonce",
codeVerifier: "verifier",
});
diff --git a/packages/api/src/models/federation.ts b/packages/api/src/models/federation.ts
index e14d5b27..b27c200e 100644
--- a/packages/api/src/models/federation.ts
+++ b/packages/api/src/models/federation.ts
@@ -1,16 +1,18 @@
-import { lookup as defaultLookup } from "node:dns/promises";
+import { lookup as defaultLookup, resolveTxt as defaultResolveTxt } from "node:dns/promises";
import { isIP } from "node:net";
import { and, asc, count, desc, eq, ilike, isNull, or } from "drizzle-orm";
import {
+ federationConnectionDomains,
federationConnections,
federationIdentities,
federationOidcStates,
+ organizationMembers,
+ organizations,
users,
} from "../db/schema.ts";
import { ConflictError, NotFoundError, ValidationError } from "../errors.ts";
import type { Context } from "../types.ts";
import { generateRandomString, sha256Base64Url } from "../utils/crypto.ts";
-import { createUser } from "./users.ts";
export type FederationAccountLinkingPolicy = "disabled" | "email_verified" | "email";
export type FederationClaimMapping = {
@@ -43,6 +45,22 @@ const DEFAULT_CLAIM_MAPPING = {
name: "name",
};
const LINKING_POLICIES = new Set(["disabled", "email_verified"]);
+const DOMAIN_STATUSES = new Set(["pending", "verified", "failed"]);
+
+const DOMAIN_VERIFICATION_PREFIX = "_darkauth-verification";
+const DOMAIN_VERIFICATION_VALUE_PREFIX = "darkauth-domain-verification";
+
+type FederationDomainVerificationStatus = "pending" | "verified" | "failed";
+
+export type TxtResolver = (hostname: string) => Promise;
+
+export function federationDomainRecordName(domain: string) {
+ return `${DOMAIN_VERIFICATION_PREFIX}.${domain}`;
+}
+
+export function federationDomainRecordValue(token: string) {
+ return `${DOMAIN_VERIFICATION_VALUE_PREFIX}=${token}`;
+}
export async function listFederationConnections(
context: Context,
@@ -129,6 +147,7 @@ export async function getFederationConnectionSecret(context: Context, id: string
export async function createFederationConnection(
context: Context,
data: {
+ organizationId?: string;
name: string;
issuer: string;
clientId: string;
@@ -142,15 +161,26 @@ export async function createFederationConnection(
scopes?: string[];
claimMapping?: FederationClaimMapping;
accountLinkingPolicy?: FederationAccountLinkingPolicy;
+ jitProvisioning?: boolean;
+ membershipOnAuthentication?: boolean;
+ requireScimPreProvisioning?: boolean;
+ requirePasswordForZk?: boolean;
+ allowPasskeyPrf?: boolean;
+ allowTrustedDeviceApproval?: boolean;
+ allowNonZkKeySetupBypass?: boolean;
domains?: string[];
enabled?: boolean;
}
) {
const now = new Date();
const normalized = normalizeConnectionInput(data);
+ const organizationId = await resolveConnectionOrganizationId(context, data.organizationId);
+ await assertVerifiedDomainsAvailable(context, normalized.domains);
const clientSecretEnc = await encryptSecret(context, data.clientSecret);
const row = {
+ organizationId,
type: "oidc",
+ protocol: "oidc",
name: normalized.name,
issuer: normalized.issuer,
clientId: normalized.clientId,
@@ -163,6 +193,13 @@ export async function createFederationConnection(
scopes: normalized.scopes,
claimMapping: normalized.claimMapping,
accountLinkingPolicy: normalized.accountLinkingPolicy,
+ jitProvisioning: normalized.jitProvisioning,
+ membershipOnAuthentication: normalized.membershipOnAuthentication,
+ requireScimPreProvisioning: normalized.requireScimPreProvisioning,
+ requirePasswordForZk: normalized.requirePasswordForZk,
+ allowPasskeyPrf: normalized.allowPasskeyPrf,
+ allowTrustedDeviceApproval: normalized.allowTrustedDeviceApproval,
+ allowNonZkKeySetupBypass: normalized.allowNonZkKeySetupBypass,
domains: normalized.domains,
enabled: normalized.enabled,
metadata: normalized.metadata,
@@ -172,6 +209,13 @@ export async function createFederationConnection(
try {
const [created] = await context.db.insert(federationConnections).values(row).returning();
if (!created) throw new ConflictError("Federation connection was not created");
+ await replaceFederationConnectionDomains(
+ context,
+ created.id,
+ created.organizationId,
+ normalized.domains,
+ { verificationStatus: "verified" }
+ );
return redactConnection(created);
} catch (error) {
if (error instanceof ConflictError) throw error;
@@ -187,7 +231,11 @@ export async function updateFederationConnection(
) {
validateId(id, "connection id");
const existing = await getFederationConnectionSecret(context, id);
+ const organizationId = updates.organizationId
+ ? await resolveConnectionOrganizationId(context, updates.organizationId)
+ : existing.organizationId;
const merged = {
+ organizationId,
name: updates.name ?? existing.name,
issuer: updates.issuer ?? existing.issuer,
clientId: updates.clientId ?? existing.clientId,
@@ -205,11 +253,25 @@ export async function updateFederationConnection(
accountLinkingPolicy:
updates.accountLinkingPolicy ??
(existing.accountLinkingPolicy as FederationAccountLinkingPolicy),
+ jitProvisioning: updates.jitProvisioning ?? existing.jitProvisioning,
+ membershipOnAuthentication:
+ updates.membershipOnAuthentication ?? existing.membershipOnAuthentication,
+ requireScimPreProvisioning:
+ updates.requireScimPreProvisioning ?? existing.requireScimPreProvisioning,
+ requirePasswordForZk: updates.requirePasswordForZk ?? existing.requirePasswordForZk,
+ allowPasskeyPrf: updates.allowPasskeyPrf ?? existing.allowPasskeyPrf,
+ allowTrustedDeviceApproval:
+ updates.allowTrustedDeviceApproval ?? existing.allowTrustedDeviceApproval,
+ allowNonZkKeySetupBypass: updates.allowNonZkKeySetupBypass ?? existing.allowNonZkKeySetupBypass,
domains: updates.domains ?? existing.domains,
enabled: updates.enabled ?? existing.enabled,
};
const normalized = normalizeConnectionInput(merged);
+ if (updates.domains || updates.organizationId) {
+ await assertVerifiedDomainsAvailable(context, normalized.domains, id);
+ }
const patch: Partial = {
+ organizationId,
name: normalized.name,
issuer: normalized.issuer,
clientId: normalized.clientId,
@@ -221,6 +283,13 @@ export async function updateFederationConnection(
scopes: normalized.scopes,
claimMapping: normalized.claimMapping,
accountLinkingPolicy: normalized.accountLinkingPolicy,
+ jitProvisioning: normalized.jitProvisioning,
+ membershipOnAuthentication: normalized.membershipOnAuthentication,
+ requireScimPreProvisioning: normalized.requireScimPreProvisioning,
+ requirePasswordForZk: normalized.requirePasswordForZk,
+ allowPasskeyPrf: normalized.allowPasskeyPrf,
+ allowTrustedDeviceApproval: normalized.allowTrustedDeviceApproval,
+ allowNonZkKeySetupBypass: normalized.allowNonZkKeySetupBypass,
domains: normalized.domains,
enabled: normalized.enabled,
metadata: normalized.metadata,
@@ -236,6 +305,15 @@ export async function updateFederationConnection(
.where(eq(federationConnections.id, id))
.returning();
if (!updated) throw new NotFoundError("Federation connection not found");
+ if (updates.domains || updates.organizationId) {
+ await replaceFederationConnectionDomains(
+ context,
+ updated.id,
+ updated.organizationId,
+ normalized.domains,
+ { verificationStatus: "verified" }
+ );
+ }
return redactConnection(updated);
} catch (error) {
if (error instanceof NotFoundError) throw error;
@@ -254,16 +332,144 @@ export async function deleteFederationConnection(context: Context, id: string) {
return { success: true } as const;
}
-export async function findFederationConnectionForEmail(context: Context, email: string) {
- const domain = extractDomain(email);
- if (!domain) throw new ValidationError("Valid email is required");
+export async function listFederationConnectionsForOrganization(
+ context: Context,
+ organizationId: string
+) {
+ validateId(organizationId, "organization id");
const rows = await context.db
.select()
.from(federationConnections)
- .where(eq(federationConnections.enabled, true))
+ .where(eq(federationConnections.organizationId, organizationId))
.orderBy(asc(federationConnections.name));
- const match = rows.find((row) => row.domains.includes(domain));
- return match ? redactConnection(match) : null;
+ return rows.map(redactConnection);
+}
+
+export async function getFederationConnectionForOrganization(
+ context: Context,
+ organizationId: string,
+ connectionId: string
+) {
+ validateId(organizationId, "organization id");
+ validateId(connectionId, "connection id");
+ const row = await context.db.query.federationConnections.findFirst({
+ where: and(
+ eq(federationConnections.id, connectionId),
+ eq(federationConnections.organizationId, organizationId)
+ ),
+ });
+ if (!row) throw new NotFoundError("Federation connection not found");
+ return redactConnection(row);
+}
+
+async function assertFederationConnectionInOrganization(
+ context: Context,
+ organizationId: string,
+ connectionId: string
+) {
+ validateId(organizationId, "organization id");
+ validateId(connectionId, "connection id");
+ const row = await context.db.query.federationConnections.findFirst({
+ where: and(
+ eq(federationConnections.id, connectionId),
+ eq(federationConnections.organizationId, organizationId)
+ ),
+ });
+ if (!row) throw new NotFoundError("Federation connection not found");
+ return row;
+}
+
+export async function updateFederationConnectionForOrganization(
+ context: Context,
+ organizationId: string,
+ connectionId: string,
+ updates: Partial[1]>
+) {
+ await assertFederationConnectionInOrganization(context, organizationId, connectionId);
+ // Never allow moving a connection to another organization through the org-scoped path.
+ const { organizationId: _ignored, ...safeUpdates } = updates;
+ return updateFederationConnection(context, connectionId, safeUpdates);
+}
+
+export async function deleteFederationConnectionForOrganization(
+ context: Context,
+ organizationId: string,
+ connectionId: string
+) {
+ await assertFederationConnectionInOrganization(context, organizationId, connectionId);
+ return deleteFederationConnection(context, connectionId);
+}
+
+export async function createFederationConnectionDomainForOrganization(
+ context: Context,
+ organizationId: string,
+ connectionId: string,
+ domain: string
+) {
+ await assertFederationConnectionInOrganization(context, organizationId, connectionId);
+ return createFederationConnectionDomain(context, { connectionId, domain });
+}
+
+export async function listFederationConnectionDomainsForOrganization(
+ context: Context,
+ organizationId: string,
+ connectionId: string
+) {
+ await assertFederationConnectionInOrganization(context, organizationId, connectionId);
+ return listFederationConnectionDomains(context, connectionId);
+}
+
+export async function deleteFederationConnectionDomainForOrganization(
+ context: Context,
+ organizationId: string,
+ connectionId: string,
+ domainId: string
+) {
+ await assertFederationConnectionInOrganization(context, organizationId, connectionId);
+ return deleteFederationConnectionDomain(context, connectionId, domainId);
+}
+
+export async function runFederationDomainDnsVerificationForOrganization(
+ context: Context,
+ organizationId: string,
+ connectionId: string,
+ domainId: string,
+ resolveTxtImpl?: TxtResolver
+) {
+ await assertFederationConnectionInOrganization(context, organizationId, connectionId);
+ return runFederationDomainDnsVerification(context, connectionId, domainId, resolveTxtImpl);
+}
+
+export async function findFederationConnectionForEmail(
+ context: Context,
+ email: string,
+ options: { organizationId?: string } = {}
+) {
+ const domain = extractDomain(email);
+ if (!domain) throw new ValidationError("Valid email is required");
+ if (options.organizationId) validateId(options.organizationId, "organization id");
+ const conditions = [
+ eq(federationConnectionDomains.domain, domain),
+ eq(federationConnectionDomains.enabled, true),
+ eq(federationConnectionDomains.verificationStatus, "verified"),
+ eq(federationConnections.enabled, true),
+ ];
+ if (options.organizationId) {
+ conditions.push(eq(federationConnections.organizationId, options.organizationId));
+ }
+ const rows = await context.db
+ .select({ connection: federationConnections })
+ .from(federationConnectionDomains)
+ .innerJoin(
+ federationConnections,
+ eq(federationConnectionDomains.connectionId, federationConnections.id)
+ )
+ .where(and(...conditions))
+ .orderBy(asc(federationConnections.name));
+ if (rows.length > 1) {
+ throw new ValidationError("Multiple federation connections match this email domain");
+ }
+ return rows[0] ? redactConnection(rows[0].connection) : null;
}
export function mapFederationClaims(
@@ -293,7 +499,7 @@ export async function resolveFederatedUserForClaims(
const connection = await getFederationConnectionSecret(context, connectionId);
if (!connection.enabled) throw new ValidationError("Federation connection is disabled");
const mapped = mapFederationClaims(claims, connection.claimMapping as FederationClaimMapping);
- if (mapped.email && !isEmailAllowedForConnection(mapped.email, connection.domains)) {
+ if (mapped.email && !(await isEmailAllowedForConnection(context, connection.id, mapped.email))) {
return { userSub: null, linked: false, created: false };
}
const identity = await context.db.query.federationIdentities.findFirst({
@@ -312,26 +518,29 @@ export async function resolveFederatedUserForClaims(
lastLoginAt: new Date(),
})
.where(eq(federationIdentities.id, identity.id));
+ const membership = await ensureFederationMembership(context, connection, identity.userSub);
+ if (!membership) {
+ return { userSub: null, identityId: identity.id, linked: true, created: false };
+ }
return { userSub: identity.userSub, identityId: identity.id, linked: true, created: false };
}
- if (connection.accountLinkingPolicy === "disabled")
- return { userSub: null, linked: false, created: false };
if (!mapped.email) return { userSub: null, linked: false, created: false };
- if (!mapped.emailVerified) return { userSub: null, linked: false, created: false };
const user = await context.db.query.users.findFirst({
where: eq(users.email, mapped.email),
});
if (!user) {
- const createdUser = await createUser(context, {
+ if (
+ !connection.jitProvisioning ||
+ !connection.membershipOnAuthentication ||
+ connection.requireScimPreProvisioning
+ ) {
+ return { userSub: null, linked: false, created: false };
+ }
+ const createdUser = await createFederatedUser(context, {
email: mapped.email,
- name: mapped.name || undefined,
+ name: mapped.name,
+ emailVerified: mapped.emailVerified,
});
- if (mapped.emailVerified) {
- await context.db
- .update(users)
- .set({ emailVerifiedAt: new Date() })
- .where(eq(users.sub, createdUser.sub));
- }
const createdIdentity = await linkFederationIdentity(context, {
connectionId: connection.id,
userSub: createdUser.sub,
@@ -341,6 +550,10 @@ export async function resolveFederatedUserForClaims(
emailVerified: mapped.emailVerified,
claims,
});
+ const membership = await ensureFederationMembership(context, connection, createdUser.sub);
+ if (!membership) {
+ return { userSub: null, identityId: createdIdentity.id, linked: true, created: true };
+ }
return {
userSub: createdUser.sub,
identityId: createdIdentity.id,
@@ -348,6 +561,9 @@ export async function resolveFederatedUserForClaims(
created: true,
};
}
+ if (connection.accountLinkingPolicy === "disabled")
+ return { userSub: null, linked: false, created: false };
+ if (!mapped.emailVerified) return { userSub: null, linked: false, created: false };
const created = await linkFederationIdentity(context, {
connectionId: connection.id,
userSub: user.sub,
@@ -357,6 +573,10 @@ export async function resolveFederatedUserForClaims(
emailVerified: mapped.emailVerified,
claims,
});
+ const membership = await ensureFederationMembership(context, connection, user.sub);
+ if (!membership) {
+ return { userSub: null, identityId: created.id, linked: true, created: true };
+ }
return { userSub: user.sub, identityId: created.id, linked: true, created: true };
}
@@ -434,6 +654,8 @@ export async function createOidcCallbackState(
context: Context,
data: {
connectionId: string;
+ organizationId: string;
+ clientId: string;
nonce: string;
codeVerifier?: string;
returnTo?: string | null;
@@ -441,6 +663,8 @@ export async function createOidcCallbackState(
}
) {
validateId(data.connectionId, "connection id");
+ validateId(data.organizationId, "organization id");
+ validateText(data.clientId, "client id");
validateText(data.nonce, "nonce");
const state = generateRandomString(32);
const now = new Date();
@@ -448,6 +672,8 @@ export async function createOidcCallbackState(
await context.db.insert(federationOidcStates).values({
stateHash: sha256Base64Url(state),
connectionId: data.connectionId,
+ organizationId: data.organizationId,
+ clientId: data.clientId,
nonceHash: sha256Base64Url(data.nonce),
codeVerifierHash: data.codeVerifier ? sha256Base64Url(data.codeVerifier) : null,
returnTo: data.returnTo ?? null,
@@ -624,6 +850,15 @@ function normalizeConnectionInput(data: Parameters)
+ : {};
return {
name,
issuer,
@@ -636,12 +871,368 @@ function normalizeConnectionInput(data: Parameters ({
+ ...row,
+ recordName: federationDomainRecordName(row.domain),
+ recordValue: federationDomainRecordValue(""),
+ }));
+}
+
+export async function deleteFederationConnectionDomain(
+ context: Context,
+ connectionId: string,
+ domainId: string
+) {
+ validateId(connectionId, "connection id");
+ validateId(domainId, "domain id");
+ const [row] = await context.db
+ .delete(federationConnectionDomains)
+ .where(
+ and(
+ eq(federationConnectionDomains.id, domainId),
+ eq(federationConnectionDomains.connectionId, connectionId)
+ )
+ )
+ .returning();
+ if (!row) throw new NotFoundError("Federation domain not found");
+ return { success: true } as const;
+}
+
+// Admin override: directly mark a domain verified without a DNS lookup.
+export async function verifyFederationConnectionDomain(
+ context: Context,
+ connectionId: string,
+ domain: string
+) {
+ validateId(connectionId, "connection id");
+ const [normalizedDomain] = normalizeDomains([domain]);
+ if (!normalizedDomain) throw new ValidationError("Valid domain is required");
+ await assertVerifiedDomainsAvailable(context, [normalizedDomain], connectionId);
+ const [row] = await context.db
+ .update(federationConnectionDomains)
+ .set({
+ verificationStatus: "verified",
+ verifiedAt: new Date(),
+ lastCheckedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(federationConnectionDomains.connectionId, connectionId),
+ eq(federationConnectionDomains.domain, normalizedDomain)
+ )
+ )
+ .returning();
+ if (!row) throw new NotFoundError("Federation domain not found");
+ return row;
+}
+
+// Real DNS TXT verification. Looks up the _darkauth-verification. TXT
+// records, checks the expected verification value is present, then marks the
+// domain verified (respecting the one-enabled-verified-owner constraint). On
+// failure the domain status is set to 'failed' and a result describing the
+// failure is returned. The resolver is injectable for tests.
+export async function runFederationDomainDnsVerification(
+ context: Context,
+ connectionId: string,
+ domainId: string,
+ resolveTxtImpl: TxtResolver = defaultResolveTxt
+) {
+ validateId(connectionId, "connection id");
+ validateId(domainId, "domain id");
+ const domainRow = await context.db.query.federationConnectionDomains.findFirst({
+ where: and(
+ eq(federationConnectionDomains.id, domainId),
+ eq(federationConnectionDomains.connectionId, connectionId)
+ ),
+ });
+ if (!domainRow) throw new NotFoundError("Federation domain not found");
+ const now = new Date();
+ if (domainRow.verificationStatus === "verified") {
+ return { status: "verified" as const, domain: domainRow };
+ }
+ if (!domainRow.verificationTokenHash) {
+ throw new ValidationError("Domain has no verification token");
+ }
+ const expectedValuePresent = await txtRecordMatchesToken(
+ resolveTxtImpl,
+ federationDomainRecordName(domainRow.domain),
+ domainRow.verificationTokenHash
+ );
+ if (!expectedValuePresent) {
+ const [failed] = await context.db
+ .update(federationConnectionDomains)
+ .set({ verificationStatus: "failed", lastCheckedAt: now, updatedAt: now })
+ .where(eq(federationConnectionDomains.id, domainRow.id))
+ .returning();
+ return {
+ status: "failed" as const,
+ domain: failed || domainRow,
+ reason: "DNS TXT verification record not found",
+ };
+ }
+ await assertVerifiedDomainsAvailable(context, [domainRow.domain], connectionId);
+ const [verified] = await context.db
+ .update(federationConnectionDomains)
+ .set({
+ verificationStatus: "verified",
+ verifiedAt: now,
+ lastCheckedAt: now,
+ updatedAt: now,
+ })
+ .where(eq(federationConnectionDomains.id, domainRow.id))
+ .returning();
+ if (!verified) throw new NotFoundError("Federation domain not found");
+ return { status: "verified" as const, domain: verified };
+}
+
+async function txtRecordMatchesToken(
+ resolveTxtImpl: TxtResolver,
+ recordName: string,
+ verificationTokenHash: string
+) {
+ let records: string[][];
+ try {
+ records = await resolveTxtImpl(recordName);
+ } catch {
+ return false;
+ }
+ for (const chunks of records) {
+ const value = chunks.join("").trim();
+ if (!value.startsWith(`${DOMAIN_VERIFICATION_VALUE_PREFIX}=`)) continue;
+ const token = value.slice(DOMAIN_VERIFICATION_VALUE_PREFIX.length + 1).trim();
+ if (token && sha256Base64Url(token) === verificationTokenHash) return true;
+ }
+ return false;
+}
+
+async function replaceFederationConnectionDomains(
+ context: Context,
+ connectionId: string,
+ organizationId: string,
+ domains: string[],
+ options: { verificationStatus: FederationDomainVerificationStatus }
+) {
+ await context.db
+ .delete(federationConnectionDomains)
+ .where(eq(federationConnectionDomains.connectionId, connectionId));
+ if (domains.length === 0) return;
+ const now = new Date();
+ await context.db.insert(federationConnectionDomains).values(
+ domains.map((domain) => ({
+ connectionId,
+ organizationId,
+ domain,
+ verificationStatus: options.verificationStatus,
+ verifiedAt: options.verificationStatus === "verified" ? now : null,
+ enabled: true,
+ createdAt: now,
+ updatedAt: now,
+ }))
+ );
+}
+
+async function assertVerifiedDomainsAvailable(
+ context: Context,
+ domains: string[],
+ connectionId?: string
+) {
+ if (domains.length === 0) return;
+ const rows = await context.db
+ .select({
+ connectionId: federationConnectionDomains.connectionId,
+ domain: federationConnectionDomains.domain,
+ })
+ .from(federationConnectionDomains)
+ .where(
+ and(
+ eq(federationConnectionDomains.enabled, true),
+ eq(federationConnectionDomains.verificationStatus, "verified")
+ )
+ );
+ const conflict = rows.find(
+ (row) => domains.includes(row.domain) && (!connectionId || row.connectionId !== connectionId)
+ );
+ if (conflict) throw new ConflictError("Federation domain is already verified");
+}
+
+async function resolveConnectionOrganizationId(context: Context, organizationId?: string) {
+ if (organizationId) {
+ validateId(organizationId, "organization id");
+ const organization = await context.db.query.organizations.findFirst({
+ where: eq(organizations.id, organizationId),
+ });
+ if (!organization) throw new ValidationError("Organization not found");
+ return organization.id;
+ }
+ const rows = await context.db.select().from(organizations).orderBy(asc(organizations.createdAt));
+ if (rows.length === 1) return rows[0]?.id as string;
+ const defaultOrganization = rows.find((row) => row.slug === "default");
+ if (defaultOrganization) return defaultOrganization.id;
+ throw new ValidationError("organizationId is required");
+}
+
+async function createFederatedUser(
+ context: Context,
+ data: { email: string; name: string | null; emailVerified: boolean }
+) {
+ const sub = generateRandomString(16);
+ const [user] = await context.db
+ .insert(users)
+ .values({
+ sub,
+ email: data.email,
+ opaqueLoginIdentity: data.email,
+ name: data.name || null,
+ emailVerifiedAt: data.emailVerified ? new Date() : null,
+ createdAt: new Date(),
+ })
+ .returning();
+ if (!user) throw new ConflictError("Unable to create user");
+ return user;
+}
+
+async function ensureFederationMembership(
+ context: Context,
+ connection: typeof federationConnections.$inferSelect,
+ userSub: string
+) {
+ const existing = await context.db.query.organizationMembers.findFirst({
+ where: and(
+ eq(organizationMembers.organizationId, connection.organizationId),
+ eq(organizationMembers.userSub, userSub)
+ ),
+ });
+ if (existing?.status === "active") return existing;
+ if (connection.requireScimPreProvisioning || !connection.membershipOnAuthentication) return null;
+ if (existing) return null;
+ const [membership] = await context.db
+ .insert(organizationMembers)
+ .values({
+ organizationId: connection.organizationId,
+ userSub,
+ status: "active",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+ return membership || null;
+}
+
+function normalizeBooleanPolicy(
+ explicit: boolean | undefined,
+ metadataValue: unknown,
+ fallback: boolean
+) {
+ if (typeof explicit === "boolean") return explicit;
+ if (typeof metadataValue === "boolean") return metadataValue;
+ return fallback;
+}
+
+function validateDomainStatus(status: string) {
+ if (!DOMAIN_STATUSES.has(status)) throw new ValidationError("Invalid domain verification status");
+}
+
function redactConnection(connection: T) {
const { clientSecretEnc: _clientSecretEnc, ...rest } = connection;
return { ...rest, hasClientSecret: !!connection.clientSecretEnc };
@@ -732,10 +1323,21 @@ function extractDomain(email: string) {
return trimmed.slice(at + 1);
}
-function isEmailAllowedForConnection(email: string, domains: string[]) {
- if (!domains || domains.length === 0) return true;
+async function isEmailAllowedForConnection(context: Context, connectionId: string, email: string) {
const domain = extractDomain(email);
- return !!domain && domains.includes(domain);
+ if (!domain) return false;
+ const rows = await context.db
+ .select({ domain: federationConnectionDomains.domain })
+ .from(federationConnectionDomains)
+ .where(
+ and(
+ eq(federationConnectionDomains.connectionId, connectionId),
+ eq(federationConnectionDomains.enabled, true),
+ eq(federationConnectionDomains.verificationStatus, "verified")
+ )
+ );
+ if (rows.length === 0) return true;
+ return rows.some((row) => row.domain === domain);
}
function validateText(value: string | undefined | null, name: string) {
diff --git a/packages/api/src/models/install.ts b/packages/api/src/models/install.ts
index 2e01d24e..ccc071b4 100644
--- a/packages/api/src/models/install.ts
+++ b/packages/api/src/models/install.ts
@@ -1,13 +1,5 @@
import { eq } from "drizzle-orm";
-import {
- adminOpaqueRecords,
- adminUsers,
- organizationMemberRoles,
- organizationMembers,
- organizations,
- roles,
- settings,
-} from "../db/schema.ts";
+import { adminOpaqueRecords, adminUsers, settings } from "../db/schema.ts";
import { ConflictError, NotFoundError } from "../errors.ts";
import type { Context } from "../types.ts";
@@ -59,7 +51,7 @@ export async function writeKdfSetting(context: Context, kdfParams: unknown) {
.values({ key: "kek_kdf", value: kdfParams, secure: true, updatedAt: new Date() });
}
-export async function ensureDefaultOrganizationAndSchema(context: Context) {
+export async function ensureOrganizationSchema(context: Context) {
try {
await context.db.execute(
`CREATE TYPE IF NOT EXISTS "organization_status" AS ENUM ('active', 'invited', 'suspended');`
@@ -96,20 +88,6 @@ export async function ensureDefaultOrganizationAndSchema(context: Context) {
);`
);
} catch {}
- try {
- await context.db.execute(
- `INSERT INTO "organizations" ("slug", "name") VALUES ('default','Default') ON CONFLICT ("slug") DO NOTHING;`
- );
- } catch {}
- try {
- await context.db.execute(
- `INSERT INTO "organization_members" ("organization_id", "user_sub", "status")
- SELECT o.id, u.sub, 'active'::organization_status
- FROM users u
- JOIN organizations o ON o.slug = 'default'
- ON CONFLICT DO NOTHING;`
- );
- } catch {}
try {
await context.db.execute(
`CREATE TABLE IF NOT EXISTS "otp_configs" (
@@ -141,25 +119,27 @@ export async function ensureDefaultOrganizationAndSchema(context: Context) {
} catch {}
try {
- const defaultOrg = await context.db.query.organizations.findFirst({
- where: eq(organizations.slug, "default"),
- });
- const memberRole = await context.db.query.roles.findFirst({ where: eq(roles.key, "member") });
- if (!defaultOrg || !memberRole) return;
- const memberships = await context.db
- .select({ id: organizationMembers.id })
- .from(organizationMembers)
- .where(eq(organizationMembers.organizationId, defaultOrg.id));
- if (memberships.length > 0) {
- await context.db
- .insert(organizationMemberRoles)
- .values(
- memberships.map((membership) => ({
- organizationMemberId: membership.id,
- roleId: memberRole.id,
- }))
- )
- .onConflictDoNothing();
- }
+ await context.db.execute(
+ `ALTER TABLE "roles"
+ ADD COLUMN IF NOT EXISTS "assignable" boolean NOT NULL DEFAULT false;`
+ );
+ await context.db.execute(
+ `ALTER TABLE "roles"
+ ADD COLUMN IF NOT EXISTS "default_member" boolean NOT NULL DEFAULT false;`
+ );
+ await context.db.execute(
+ `ALTER TABLE "roles"
+ ADD COLUMN IF NOT EXISTS "default_creator" boolean NOT NULL DEFAULT false;`
+ );
+ await context.db.execute(
+ `UPDATE "roles"
+ SET "assignable" = true, "default_member" = true, "updated_at" = now()
+ WHERE "key" = 'member';`
+ );
+ await context.db.execute(
+ `UPDATE "roles"
+ SET "assignable" = true, "default_creator" = true, "updated_at" = now()
+ WHERE "key" = 'org_admin';`
+ );
} catch {}
}
diff --git a/packages/api/src/models/organizations.test.ts b/packages/api/src/models/organizations.test.ts
index b7213cec..18ee7a49 100644
--- a/packages/api/src/models/organizations.test.ts
+++ b/packages/api/src/models/organizations.test.ts
@@ -17,7 +17,12 @@ import {
} from "../db/schema.ts";
import { ValidationError } from "../errors.ts";
import type { Context } from "../types.ts";
-import { createOrganizationInvite } from "./organizations.ts";
+import {
+ createOrganizationInvite,
+ leaveOrganization,
+ listOrganizationsForUser,
+ removeOrganizationMember,
+} from "./organizations.ts";
function createLogger() {
return {
@@ -51,7 +56,7 @@ test("createOrganizationInvite rejects unknown or non-assignable role ids", asyn
const [managerRole] = await db
.insert(roles)
- .values({ key: "manager", name: "Manager", system: true })
+ .values({ key: "manager", name: "Manager", assignable: true })
.returning();
assert.ok(managerRole);
@@ -95,3 +100,140 @@ test("createOrganizationInvite rejects unknown or non-assignable role ids", asyn
fs.rmSync(directory, { recursive: true, force: true });
}
});
+
+test("listOrganizationsForUser includes role summaries", async () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-org-list-roles-test-"));
+ const { db, close } = await createPglite(directory);
+ const context = { db, logger: createLogger() } as Context;
+
+ try {
+ await db.insert(users).values({ sub: "user-roles", email: "roles@example.com", name: "Roles" });
+ const [organization] = await db
+ .insert(organizations)
+ .values({ slug: "roles-org", name: "Roles Org", createdByUserSub: "user-roles" })
+ .returning();
+ assert.ok(organization);
+ const [membership] = await db
+ .insert(organizationMembers)
+ .values({ organizationId: organization.id, userSub: "user-roles", status: "active" })
+ .returning();
+ assert.ok(membership);
+ const [adminRole] = await db
+ .insert(roles)
+ .values({ key: "org_roles_summary", name: "Role Summary" })
+ .returning();
+ assert.ok(adminRole);
+ await db
+ .insert(organizationMemberRoles)
+ .values({ organizationMemberId: membership.id, roleId: adminRole.id });
+
+ const result = await listOrganizationsForUser(context, "user-roles");
+
+ assert.deepEqual(result, [
+ {
+ organizationId: organization.id,
+ slug: "roles-org",
+ name: "Roles Org",
+ forceOtp: false,
+ membershipId: membership.id,
+ status: "active",
+ roles: [{ id: adminRole.id, key: "org_roles_summary", name: "Role Summary" }],
+ },
+ ]);
+ } finally {
+ await close();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test("leaveOrganization rejects the user's only active organization", async () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-org-leave-test-"));
+ const { db, close } = await createPglite(directory);
+ const context = { db, logger: createLogger() } as Context;
+
+ try {
+ await db.insert(users).values({ sub: "user-1", email: "user-1@example.com", name: "User One" });
+ const [organization] = await db
+ .insert(organizations)
+ .values({ slug: "org-1", name: "Org One", createdByUserSub: "user-1" })
+ .returning();
+ assert.ok(organization);
+ await db
+ .insert(organizationMembers)
+ .values({ organizationId: organization.id, userSub: "user-1", status: "active" });
+
+ await assert.rejects(
+ () => leaveOrganization(context, "user-1", organization.id),
+ (error: unknown) => {
+ assert.ok(error instanceof ValidationError);
+ assert.equal(error.message, "User must belong to at least one active organization");
+ return true;
+ }
+ );
+ } finally {
+ await close();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test("removeOrganizationMember rejects removing the last managing member", async () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-org-remove-test-"));
+ const { db, close } = await createPglite(directory);
+ const context = { db, logger: createLogger() } as Context;
+
+ try {
+ await db.insert(users).values([
+ { sub: "manager", email: "manager@example.com", name: "Manager" },
+ { sub: "member", email: "member@example.com", name: "Member" },
+ ]);
+ const [organization] = await db
+ .insert(organizations)
+ .values({ slug: "org-1", name: "Org One", createdByUserSub: "manager" })
+ .returning();
+ const [otherOrganization] = await db
+ .insert(organizations)
+ .values({ slug: "org-2", name: "Org Two" })
+ .returning();
+ assert.ok(organization);
+ assert.ok(otherOrganization);
+
+ const [managerMembership] = await db
+ .insert(organizationMembers)
+ .values({ organizationId: organization.id, userSub: "manager", status: "active" })
+ .returning();
+ await db.insert(organizationMembers).values({
+ organizationId: otherOrganization.id,
+ userSub: "manager",
+ status: "active",
+ });
+ assert.ok(managerMembership);
+
+ const [managerRole] = await db
+ .insert(roles)
+ .values({ key: "manager-remove", name: "Manager Remove" })
+ .returning();
+ assert.ok(managerRole);
+ await db
+ .insert(permissions)
+ .values({ key: "darkauth.org:manage", description: "Manage org" })
+ .onConflictDoNothing();
+ await db
+ .insert(rolePermissions)
+ .values({ roleId: managerRole.id, permissionKey: "darkauth.org:manage" });
+ await db
+ .insert(organizationMemberRoles)
+ .values({ organizationMemberId: managerMembership.id, roleId: managerRole.id });
+
+ await assert.rejects(
+ () => removeOrganizationMember(context, "manager", organization.id, managerMembership.id),
+ (error: unknown) => {
+ assert.ok(error instanceof ValidationError);
+ assert.equal(error.message, "Organization must retain at least one managing member");
+ return true;
+ }
+ );
+ } finally {
+ await close();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
diff --git a/packages/api/src/models/organizations.ts b/packages/api/src/models/organizations.ts
index 7b383608..cf97ab55 100644
--- a/packages/api/src/models/organizations.ts
+++ b/packages/api/src/models/organizations.ts
@@ -1,4 +1,4 @@
-import { and, eq, inArray } from "drizzle-orm";
+import { and, count, eq, inArray } from "drizzle-orm";
import {
organizationInvites,
organizationMemberRoles,
@@ -12,13 +12,193 @@ import type { Context } from "../types.ts";
import { generateRandomString, sha256Base64Url } from "../utils/crypto.ts";
import { getUserOrgAccess } from "./rbac.ts";
+const personalSlugWords = [
+ "amber",
+ "blue",
+ "bright",
+ "clear",
+ "green",
+ "north",
+ "silver",
+ "swift",
+ "star",
+ "stone",
+ "field",
+ "harbor",
+ "ridge",
+ "signal",
+ "spark",
+ "vault",
+ "wave",
+ "willow",
+];
+
+type DbLike = Pick;
+
+function cleanSlug(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .replace(/-+/g, "-");
+}
+
+function randomSlugPart() {
+ const index = generateRandomString(2).charCodeAt(0) % personalSlugWords.length;
+ return personalSlugWords[index] || "bright";
+}
+
+function randomSlugSuffix() {
+ const suffix = generateRandomString(6)
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, "")
+ .slice(0, 6);
+ return suffix || Date.now().toString(36).slice(-6);
+}
+
+export function personalOrganizationName(name?: string | null) {
+ const trimmed = typeof name === "string" ? name.trim() : "";
+ return trimmed ? `${trimmed}'s Personal` : "Personal Organization";
+}
+
+export function generatePersonalOrganizationSlug() {
+ return `${randomSlugPart()}-${randomSlugPart()}-${randomSlugPart()}-${randomSlugSuffix()}`;
+}
+
+async function getDefaultRoleIds(db: DbLike, flag: "defaultMember" | "defaultCreator") {
+ const column = flag === "defaultMember" ? roles.defaultMember : roles.defaultCreator;
+ const rows = await db.select({ id: roles.id }).from(roles).where(eq(column, true));
+ if (rows.length === 0) {
+ throw new ValidationError(
+ flag === "defaultMember"
+ ? "At least one default member role is required"
+ : "At least one default creator role is required"
+ );
+ }
+ return rows.map((role) => role.id);
+}
+
+async function assignRolesToMembership(db: DbLike, membershipId: string, roleIds: string[]) {
+ const deduped = Array.from(new Set(roleIds));
+ if (deduped.length === 0) return;
+ await db
+ .insert(organizationMemberRoles)
+ .values(deduped.map((roleId) => ({ organizationMemberId: membershipId, roleId })))
+ .onConflictDoNothing();
+}
+
+export async function createActiveMembershipWithDefaultRoles(
+ db: DbLike,
+ organizationId: string,
+ userSub: string,
+ includeCreatorRoles: boolean
+) {
+ const [membership] = await db
+ .insert(organizationMembers)
+ .values({
+ organizationId,
+ userSub,
+ status: "active",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .onConflictDoNothing()
+ .returning();
+
+ const activeMembership =
+ membership ||
+ (await db.query.organizationMembers.findFirst({
+ where: and(
+ eq(organizationMembers.organizationId, organizationId),
+ eq(organizationMembers.userSub, userSub)
+ ),
+ }));
+ if (!activeMembership) throw new ValidationError("Failed to create organization membership");
+
+ if (activeMembership.status !== "active") {
+ const [updatedMembership] = await db
+ .update(organizationMembers)
+ .set({ status: "active", updatedAt: new Date() })
+ .where(eq(organizationMembers.id, activeMembership.id))
+ .returning();
+ if (!updatedMembership) throw new ValidationError("Failed to create organization membership");
+ await db
+ .delete(organizationMemberRoles)
+ .where(eq(organizationMemberRoles.organizationMemberId, updatedMembership.id));
+ const roleIds = await getMembershipDefaultRoleIds(db, includeCreatorRoles);
+ await assignRolesToMembership(db, updatedMembership.id, roleIds);
+ return updatedMembership;
+ }
+
+ const roleIds = await getMembershipDefaultRoleIds(db, includeCreatorRoles);
+ await assignRolesToMembership(db, activeMembership.id, roleIds);
+ return activeMembership;
+}
+
+async function getMembershipDefaultRoleIds(db: DbLike, includeCreatorRoles: boolean) {
+ const memberRoleIds = await getDefaultRoleIds(db, "defaultMember");
+ if (!includeCreatorRoles) return memberRoleIds;
+ const creatorRoleIds = await getDefaultRoleIds(db, "defaultCreator");
+ return [...memberRoleIds, ...creatorRoleIds];
+}
+
+export async function createPersonalOrganizationForUser(
+ db: DbLike,
+ userSub: string,
+ displayName?: string | null,
+ options: { name?: string; slug?: string } = {}
+) {
+ const name = options.name?.trim() || personalOrganizationName(displayName);
+ const requestedSlug = options.slug ? cleanSlug(options.slug) : undefined;
+ if (requestedSlug !== undefined && !requestedSlug) {
+ throw new ValidationError("Organization slug is required");
+ }
+
+ for (let attempt = 0; attempt < 12; attempt += 1) {
+ const slug = requestedSlug || generatePersonalOrganizationSlug();
+ const [created] = await db
+ .insert(organizations)
+ .values({
+ slug,
+ name,
+ createdByUserSub: userSub,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .onConflictDoNothing()
+ .returning();
+
+ if (created) {
+ const membership = await createActiveMembershipWithDefaultRoles(
+ db,
+ created.id,
+ userSub,
+ true
+ );
+ return {
+ organizationId: created.id,
+ slug: created.slug,
+ name: created.name,
+ forceOtp: created.forceOtp,
+ membershipId: membership.id,
+ status: membership.status,
+ };
+ }
+
+ if (requestedSlug) throw new ValidationError("Organization slug already exists");
+ }
+
+ throw new ValidationError("Failed to generate organization slug");
+}
+
async function validateAssignableRoleIds(context: Context, roleIds: string[]) {
const dedupedRoleIds = Array.from(new Set(roleIds));
if (dedupedRoleIds.length === 0) return [];
const existingRoles = await context.db
.select({ id: roles.id })
.from(roles)
- .where(and(inArray(roles.id, dedupedRoleIds), eq(roles.system, true)));
+ .where(and(inArray(roles.id, dedupedRoleIds), eq(roles.assignable, true)));
if (existingRoles.length !== dedupedRoleIds.length) {
throw new ValidationError("One or more roles were not found or cannot be assigned");
}
@@ -26,7 +206,7 @@ async function validateAssignableRoleIds(context: Context, roleIds: string[]) {
}
export async function listOrganizationsForUser(context: Context, userSub: string) {
- return context.db
+ const rows = await context.db
.select({
organizationId: organizations.id,
slug: organizations.slug,
@@ -38,6 +218,36 @@ export async function listOrganizationsForUser(context: Context, userSub: string
.from(organizationMembers)
.innerJoin(organizations, eq(organizationMembers.organizationId, organizations.id))
.where(and(eq(organizationMembers.userSub, userSub), eq(organizationMembers.status, "active")));
+
+ if (rows.length === 0) return [];
+
+ const roleRows = await context.db
+ .select({
+ membershipId: organizationMemberRoles.organizationMemberId,
+ id: roles.id,
+ key: roles.key,
+ name: roles.name,
+ })
+ .from(organizationMemberRoles)
+ .innerJoin(roles, eq(organizationMemberRoles.roleId, roles.id))
+ .where(
+ inArray(
+ organizationMemberRoles.organizationMemberId,
+ rows.map((row) => row.membershipId)
+ )
+ );
+
+ const rolesByMembership = new Map>();
+ for (const role of roleRows) {
+ const roleList = rolesByMembership.get(role.membershipId) || [];
+ roleList.push({ id: role.id, key: role.key, name: role.name });
+ rolesByMembership.set(role.membershipId, roleList);
+ }
+
+ return rows.map((row) => ({
+ ...row,
+ roles: rolesByMembership.get(row.membershipId) || [],
+ }));
}
export async function getOrganizationForUser(
@@ -108,52 +318,36 @@ export async function createOrganization(
) {
const name = data.name.trim();
if (!name) throw new ValidationError("Organization name is required");
- const slug = (data.slug || name)
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9-]+/g, "-")
- .replace(/^-+|-+$/g, "");
- if (!slug) throw new ValidationError("Organization slug is required");
+ const requestedSlug = data.slug ? cleanSlug(data.slug) : undefined;
+ if (data.slug !== undefined && !requestedSlug)
+ throw new ValidationError("Organization slug is required");
return context.db.transaction(async (trx) => {
- const [created] = await trx
- .insert(organizations)
- .values({
- slug,
- name,
- forceOtp: data.forceOtp === true,
- createdByUserSub: userSub,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .onConflictDoNothing()
- .returning();
-
- if (!created) {
- throw new ValidationError("Organization slug already exists");
+ let created: typeof organizations.$inferSelect | undefined;
+ for (let attempt = 0; attempt < 12; attempt += 1) {
+ const slug = requestedSlug || generatePersonalOrganizationSlug();
+ const [row] = await trx
+ .insert(organizations)
+ .values({
+ slug,
+ name,
+ forceOtp: data.forceOtp === true,
+ createdByUserSub: userSub,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .onConflictDoNothing()
+ .returning();
+ if (row) {
+ created = row;
+ break;
+ }
+ if (requestedSlug) throw new ValidationError("Organization slug already exists");
}
- const [membership] = await trx
- .insert(organizationMembers)
- .values({
- organizationId: created.id,
- userSub,
- status: "active",
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
- if (!membership) {
- throw new ValidationError("Failed to create organization membership");
- }
+ if (!created) throw new ValidationError("Failed to generate organization slug");
- const orgAdminRole = await trx.query.roles.findFirst({ where: eq(roles.key, "org_admin") });
- if (orgAdminRole) {
- await trx
- .insert(organizationMemberRoles)
- .values({ organizationMemberId: membership.id, roleId: orgAdminRole.id })
- .onConflictDoNothing();
- }
+ const membership = await createActiveMembershipWithDefaultRoles(trx, created.id, userSub, true);
return {
organizationId: created.id,
@@ -166,6 +360,23 @@ export async function createOrganization(
});
}
+export async function listAssignableRoles(
+ context: Context,
+ userSub: string,
+ organizationId: string
+) {
+ await requireOrganizationManagePermission(context, userSub, organizationId);
+ return context.db
+ .select({
+ id: roles.id,
+ key: roles.key,
+ name: roles.name,
+ description: roles.description,
+ })
+ .from(roles)
+ .where(eq(roles.assignable, true));
+}
+
export async function listOrganizationMembers(
context: Context,
userSub: string,
@@ -315,3 +526,113 @@ export async function removeMemberRole(
return { success: true as const };
}
+
+async function activeOrganizationCountForUser(context: Context, userSub: string) {
+ const rows = await context.db
+ .select({ count: count() })
+ .from(organizationMembers)
+ .where(and(eq(organizationMembers.userSub, userSub), eq(organizationMembers.status, "active")));
+ return Number(rows[0]?.count || 0);
+}
+
+async function activeMembersForOrganization(context: Context, organizationId: string) {
+ return context.db
+ .select({ membershipId: organizationMembers.id, userSub: organizationMembers.userSub })
+ .from(organizationMembers)
+ .where(
+ and(
+ eq(organizationMembers.organizationId, organizationId),
+ eq(organizationMembers.status, "active")
+ )
+ );
+}
+
+async function userHasOrganizationManagePermission(
+ context: Context,
+ userSub: string,
+ organizationId: string
+) {
+ const access = await getUserOrgAccess(context, userSub, organizationId);
+ return access.permissions.includes("darkauth.org:manage");
+}
+
+async function assertRemovalKeepsOrganizationManageAuthority(
+ context: Context,
+ organizationId: string,
+ removedMemberId: string
+) {
+ const members = await activeMembersForOrganization(context, organizationId);
+ for (const member of members) {
+ if (member.membershipId === removedMemberId) continue;
+ if (await userHasOrganizationManagePermission(context, member.userSub, organizationId)) return;
+ }
+ throw new ValidationError("Organization must retain at least one managing member");
+}
+
+async function assertMemberCanBeRemoved(
+ context: Context,
+ organizationId: string,
+ member: { id: string; userSub: string }
+) {
+ const memberships = await activeOrganizationCountForUser(context, member.userSub);
+ if (memberships <= 1) {
+ throw new ValidationError("User must belong to at least one active organization");
+ }
+ if (await userHasOrganizationManagePermission(context, member.userSub, organizationId)) {
+ await assertRemovalKeepsOrganizationManageAuthority(context, organizationId, member.id);
+ }
+}
+
+export async function removeOrganizationMember(
+ context: Context,
+ userSub: string,
+ organizationId: string,
+ memberId: string
+) {
+ await requireOrganizationManagePermission(context, userSub, organizationId);
+ const member = await context.db.query.organizationMembers.findFirst({
+ where: and(
+ eq(organizationMembers.id, memberId),
+ eq(organizationMembers.organizationId, organizationId),
+ eq(organizationMembers.status, "active")
+ ),
+ });
+ if (!member) throw new NotFoundError("Organization member not found");
+ await assertMemberCanBeRemoved(context, organizationId, member);
+ await context.db.delete(organizationMembers).where(eq(organizationMembers.id, member.id));
+ return { success: true as const };
+}
+
+export async function leaveOrganization(context: Context, userSub: string, organizationId: string) {
+ const membership = await getOrganizationForUser(context, userSub, organizationId);
+ if (!membership) throw new NotFoundError("Organization not found");
+ await assertMemberCanBeRemoved(context, organizationId, {
+ id: membership.membershipId,
+ userSub,
+ });
+ await context.db
+ .delete(organizationMembers)
+ .where(eq(organizationMembers.id, membership.membershipId));
+ return { success: true as const };
+}
+
+export async function deleteOrganization(
+ context: Context,
+ userSub: string,
+ organizationId: string
+) {
+ await requireOrganizationManagePermission(context, userSub, organizationId);
+ const members = await activeMembersForOrganization(context, organizationId);
+ for (const member of members) {
+ const memberships = await activeOrganizationCountForUser(context, member.userSub);
+ if (memberships <= 1) {
+ throw new ValidationError("Deleting organization would leave a user without an organization");
+ }
+ }
+ const [deleted] = await context.db
+ .delete(organizations)
+ .where(eq(organizations.id, organizationId))
+ .returning();
+ if (!deleted) throw new NotFoundError("Organization not found");
+ return { success: true as const };
+}
diff --git a/packages/api/src/models/rbac.ts b/packages/api/src/models/rbac.ts
index e7681796..474796b6 100644
--- a/packages/api/src/models/rbac.ts
+++ b/packages/api/src/models/rbac.ts
@@ -30,6 +30,11 @@ export async function getUserOrganizations(context: Context, userSub: string) {
return rows;
}
+export async function isUserOtpRequired(context: Context, userSub: string): Promise {
+ const organizations = await getUserOrganizations(context, userSub);
+ return organizations.some((membership) => membership.status === "active" && membership.forceOtp);
+}
+
export async function hasUserActiveMembership(
context: Context,
userSub: string,
diff --git a/packages/api/src/models/rbacAdmin.test.ts b/packages/api/src/models/rbacAdmin.test.ts
index 1132caaf..99bf5276 100644
--- a/packages/api/src/models/rbacAdmin.test.ts
+++ b/packages/api/src/models/rbacAdmin.test.ts
@@ -4,6 +4,7 @@ import { NotFoundError, ValidationError } from "../errors.ts";
import type { Context } from "../types.ts";
import {
addOrganizationMemberRolesAdmin,
+ deleteRoleAdmin,
removeOrganizationMemberRoleAdmin,
setRolePermissionsAdmin,
updateRoleAdmin,
@@ -107,3 +108,55 @@ test("removeOrganizationMemberRoleAdmin rejects unknown member", async () => {
}
);
});
+
+test("updateRoleAdmin rejects removing the last default member role", async () => {
+ const context = {
+ db: {
+ query: {
+ roles: {
+ findFirst: async () => ({ id: "role-1", system: false, defaultMember: true }),
+ },
+ },
+ select: () => ({
+ from: () => ({
+ where: async () => [{ count: 0 }],
+ }),
+ }),
+ },
+ } as unknown as Context;
+
+ await assert.rejects(
+ () => updateRoleAdmin(context, "role-1", { defaultMember: false }),
+ (error: unknown) => {
+ assert.ok(error instanceof ValidationError);
+ assert.equal(error.message, "At least one default member role is required");
+ return true;
+ }
+ );
+});
+
+test("deleteRoleAdmin rejects deleting the last default creator role", async () => {
+ const context = {
+ db: {
+ query: {
+ roles: {
+ findFirst: async () => ({ id: "role-1", system: false, defaultCreator: true }),
+ },
+ },
+ select: () => ({
+ from: () => ({
+ where: async () => [{ count: 0 }],
+ }),
+ }),
+ },
+ } as unknown as Context;
+
+ await assert.rejects(
+ () => deleteRoleAdmin(context, "role-1"),
+ (error: unknown) => {
+ assert.ok(error instanceof ValidationError);
+ assert.equal(error.message, "At least one default creator role is required");
+ return true;
+ }
+ );
+});
diff --git a/packages/api/src/models/rbacAdmin.ts b/packages/api/src/models/rbacAdmin.ts
index 3dc2a371..d2a59bbd 100644
--- a/packages/api/src/models/rbacAdmin.ts
+++ b/packages/api/src/models/rbacAdmin.ts
@@ -490,6 +490,9 @@ export async function listRolesAdmin(
name: roles.name,
description: roles.description,
system: roles.system,
+ assignable: roles.assignable,
+ defaultMember: roles.defaultMember,
+ defaultCreator: roles.defaultCreator,
})
.from(roles)
.where(searchCondition)
@@ -500,6 +503,9 @@ export async function listRolesAdmin(
name: roles.name,
description: roles.description,
system: roles.system,
+ assignable: roles.assignable,
+ defaultMember: roles.defaultMember,
+ defaultCreator: roles.defaultCreator,
})
.from(roles)
)
@@ -541,7 +547,15 @@ export async function listRolesAdmin(
export async function createRoleAdmin(
context: Context,
- data: { key: string; name: string; description?: string | null; permissionKeys?: string[] }
+ data: {
+ key: string;
+ name: string;
+ description?: string | null;
+ permissionKeys?: string[];
+ assignable?: boolean;
+ defaultMember?: boolean;
+ defaultCreator?: boolean;
+ }
) {
const key = data.key.trim();
const name = data.name.trim();
@@ -567,6 +581,9 @@ export async function createRoleAdmin(
name,
description: data.description || null,
system: false,
+ assignable: data.assignable === true,
+ defaultMember: data.defaultMember === true,
+ defaultCreator: data.defaultCreator === true,
createdAt: new Date(),
updatedAt: new Date(),
})
@@ -596,6 +613,9 @@ export async function getRoleAdmin(context: Context, roleId: string) {
name: roles.name,
description: roles.description,
system: roles.system,
+ assignable: roles.assignable,
+ defaultMember: roles.defaultMember,
+ defaultCreator: roles.defaultCreator,
})
.from(roles)
.where(eq(roles.id, roleId))
@@ -614,26 +634,104 @@ export async function getRoleAdmin(context: Context, roleId: string) {
};
}
+async function countOtherDefaultRoles(
+ context: Context,
+ roleId: string,
+ flag: "defaultMember" | "defaultCreator"
+) {
+ const column = flag === "defaultMember" ? roles.defaultMember : roles.defaultCreator;
+ const rows = await context.db
+ .select({ count: count() })
+ .from(roles)
+ .where(and(eq(column, true), sql`${roles.id} <> ${roleId}`));
+ return Number(rows[0]?.count || 0);
+}
+
+async function ensureDefaultFlagCanChange(
+ context: Context,
+ roleId: string,
+ existing: { defaultMember?: boolean; defaultCreator?: boolean },
+ updates: { defaultMember?: boolean; defaultCreator?: boolean }
+) {
+ if (existing.defaultMember && updates.defaultMember === false) {
+ const remaining = await countOtherDefaultRoles(context, roleId, "defaultMember");
+ if (remaining === 0) throw new ValidationError("At least one default member role is required");
+ }
+ if (existing.defaultCreator && updates.defaultCreator === false) {
+ const remaining = await countOtherDefaultRoles(context, roleId, "defaultCreator");
+ if (remaining === 0) throw new ValidationError("At least one default creator role is required");
+ }
+}
+
+async function ensureDefaultRoleCanBeDeleted(
+ context: Context,
+ roleId: string,
+ existing: { defaultMember?: boolean; defaultCreator?: boolean }
+) {
+ if (existing.defaultMember) {
+ const remaining = await countOtherDefaultRoles(context, roleId, "defaultMember");
+ if (remaining === 0)
+ throw new ValidationError(
+ "At least one default member role is required",
+ undefined,
+ "LAST_DEFAULT_MEMBER_ROLE"
+ );
+ }
+ if (existing.defaultCreator) {
+ const remaining = await countOtherDefaultRoles(context, roleId, "defaultCreator");
+ if (remaining === 0)
+ throw new ValidationError(
+ "At least one default creator role is required",
+ undefined,
+ "LAST_DEFAULT_CREATOR_ROLE"
+ );
+ }
+}
+
export async function updateRoleAdmin(
context: Context,
roleId: string,
- data: { name?: string; description?: string | null }
+ data: {
+ name?: string;
+ description?: string | null;
+ assignable?: boolean;
+ defaultMember?: boolean;
+ defaultCreator?: boolean;
+ }
) {
const existing = await context.db.query.roles.findFirst({ where: eq(roles.id, roleId) });
if (!existing) throw new NotFoundError("Role not found");
- const updates: { name?: string; description?: string | null; updatedAt: Date } = {
+ const updates: {
+ name?: string;
+ description?: string | null;
+ assignable?: boolean;
+ defaultMember?: boolean;
+ defaultCreator?: boolean;
+ updatedAt: Date;
+ } = {
updatedAt: new Date(),
};
if (typeof data.name === "string") updates.name = data.name.trim();
if (Object.hasOwn(data, "description")) {
updates.description = data.description ?? null;
}
-
- if (!updates.name && !Object.hasOwn(data, "description")) {
+ if (typeof data.assignable === "boolean") updates.assignable = data.assignable;
+ if (typeof data.defaultMember === "boolean") updates.defaultMember = data.defaultMember;
+ if (typeof data.defaultCreator === "boolean") updates.defaultCreator = data.defaultCreator;
+
+ if (
+ !updates.name &&
+ !Object.hasOwn(data, "description") &&
+ updates.assignable === undefined &&
+ updates.defaultMember === undefined &&
+ updates.defaultCreator === undefined
+ ) {
throw new ValidationError("No updates provided");
}
+ await ensureDefaultFlagCanChange(context, roleId, existing, updates);
+
const [updated] = await context.db
.update(roles)
.set(updates)
@@ -647,7 +745,9 @@ export async function updateRoleAdmin(
export async function deleteRoleAdmin(context: Context, roleId: string) {
const existing = await context.db.query.roles.findFirst({ where: eq(roles.id, roleId) });
if (!existing) throw new NotFoundError("Role not found");
- if (existing.system) throw new ValidationError("System roles cannot be deleted");
+ if (existing.system)
+ throw new ValidationError("System roles cannot be deleted", undefined, "SYSTEM_ROLE_PROTECTED");
+ await ensureDefaultRoleCanBeDeleted(context, roleId, existing);
await context.db.delete(roles).where(eq(roles.id, roleId));
return { success: true as const };
diff --git a/packages/api/src/models/registration.test.ts b/packages/api/src/models/registration.test.ts
index 985e0e4e..f568fb66 100644
--- a/packages/api/src/models/registration.test.ts
+++ b/packages/api/src/models/registration.test.ts
@@ -3,8 +3,15 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
+import { eq } from "drizzle-orm";
import { createPglite } from "../db/pglite.ts";
-import { users } from "../db/schema.ts";
+import {
+ organizationMemberRoles,
+ organizationMembers,
+ organizations,
+ roles,
+ users,
+} from "../db/schema.ts";
import { ConflictError } from "../errors.ts";
import { setSetting } from "../services/settings.ts";
import type { Context } from "../types.ts";
@@ -166,3 +173,45 @@ test("duplicate registration returns conflict when anti-enumeration path cannot
await cleanup();
}
});
+
+test("registration creates a personal organization with default member and creator roles", async () => {
+ const { context, cleanup } = await createTestContext();
+ try {
+ const result = await userOpaqueRegisterFinish(context, {
+ record: new Uint8Array([9, 9, 9]),
+ email: "new-personal@example.com",
+ name: "New Personal",
+ });
+
+ assert.equal(result.requiresEmailVerification, false);
+ assert.ok(result.sessionId);
+
+ const organizationRows = await context.db
+ .select({
+ id: organizations.id,
+ slug: organizations.slug,
+ name: organizations.name,
+ createdByUserSub: organizations.createdByUserSub,
+ })
+ .from(organizations)
+ .where(eq(organizations.createdByUserSub, result.sub));
+ assert.equal(organizationRows.length, 1);
+ assert.equal(organizationRows[0]?.name, "New Personal's Personal");
+ assert.match(organizationRows[0]?.slug || "", /^[a-z]+-[a-z]+-[a-z]+-[a-z0-9]{1,6}$/);
+
+ const membershipRows = await context.db
+ .select({ id: organizationMembers.id })
+ .from(organizationMembers)
+ .where(eq(organizationMembers.userSub, result.sub));
+ assert.equal(membershipRows.length, 1);
+
+ const roleRows = await context.db
+ .select({ key: roles.key })
+ .from(organizationMemberRoles)
+ .innerJoin(roles, eq(organizationMemberRoles.roleId, roles.id))
+ .where(eq(organizationMemberRoles.organizationMemberId, membershipRows[0]?.id || ""));
+ assert.deepEqual(roleRows.map((role) => role.key).sort(), ["member", "org_admin"]);
+ } finally {
+ await cleanup();
+ }
+});
diff --git a/packages/api/src/models/registration.ts b/packages/api/src/models/registration.ts
index bf6f690e..c2a15b02 100644
--- a/packages/api/src/models/registration.ts
+++ b/packages/api/src/models/registration.ts
@@ -1,12 +1,5 @@
import { eq } from "drizzle-orm";
-import {
- opaqueRecords,
- organizationMemberRoles,
- organizationMembers,
- organizations,
- roles,
- users,
-} from "../db/schema.ts";
+import { opaqueRecords, users } from "../db/schema.ts";
import { ConflictError, ValidationError } from "../errors.ts";
import { isEmailSendingAvailable } from "../services/email.ts";
import {
@@ -16,6 +9,7 @@ import {
import { createSession } from "../services/sessions.ts";
import { getSetting } from "../services/settings.ts";
import type { Context } from "../types.ts";
+import { createPersonalOrganizationForUser } from "./organizations.ts";
export async function userOpaqueRegisterFinish(
context: Context,
@@ -51,7 +45,7 @@ export async function userOpaqueRegisterFinish(
throw new ConflictError("A user with this email address already exists");
}
- await context.db.transaction(async (tx) => {
+ const createdOrganization = await context.db.transaction(async (tx) => {
await tx.insert(users).values({
sub,
email: data.email,
@@ -60,34 +54,14 @@ export async function userOpaqueRegisterFinish(
emailVerifiedAt: requireEmailVerification ? null : new Date(),
createdAt: new Date(),
});
- const defaultOrg = await tx.query.organizations.findFirst({
- where: eq(organizations.slug, "default"),
- });
- if (defaultOrg) {
- const [membership] = await tx
- .insert(organizationMembers)
- .values({
- organizationId: defaultOrg.id,
- userSub: sub,
- status: "active",
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
- const memberRole = await tx.query.roles.findFirst({ where: eq(roles.key, "member") });
- if (membership && memberRole) {
- await tx
- .insert(organizationMemberRoles)
- .values({ organizationMemberId: membership.id, roleId: memberRole.id })
- .onConflictDoNothing();
- }
- }
+ const organization = await createPersonalOrganizationForUser(tx, sub, data.name);
await tx.insert(opaqueRecords).values({
sub,
envelope: Buffer.from(opaqueRecord.envelope),
serverPubkey: Buffer.from(opaqueRecord.serverPublicKey),
updatedAt: new Date(),
});
+ return organization;
});
if (requireEmailVerification) {
await sendSignupVerification(context, {
@@ -112,6 +86,8 @@ export async function userOpaqueRegisterFinish(
name: data.name,
clientId: userClientId,
keyState: "unlocked",
+ organizationId: createdOrganization?.organizationId,
+ organizationSlug: createdOrganization?.slug,
});
return {
sub,
diff --git a/packages/api/src/models/scim.test.ts b/packages/api/src/models/scim.test.ts
index e015fea4..d98bddc6 100644
--- a/packages/api/src/models/scim.test.ts
+++ b/packages/api/src/models/scim.test.ts
@@ -1,9 +1,10 @@
import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { createPglite } from "../db/pglite.ts";
import {
authCodes,
@@ -35,6 +36,7 @@ import {
patchScimUser,
requireScimBearerToken,
revokeScimBearerToken,
+ revokeScimBearerTokenForOrganization,
} from "./scim.ts";
function createLogger() {
@@ -60,16 +62,48 @@ async function withContext(run: (context: Context) => Promise) {
}
}
+async function createOrganization(
+ context: Context,
+ id = randomUUID(),
+ slug = `org-${id.slice(0, 8)}`
+) {
+ await context.db.insert(organizations).values({
+ id,
+ slug,
+ name: slug,
+ });
+ return { id, slug };
+}
+
+async function createScimAuth(
+ context: Context,
+ organizationId?: string,
+ organizationSlug?: string
+) {
+ const organization = await createOrganization(context, organizationId, organizationSlug);
+ const token = await createScimBearerToken(context, {
+ name: "Directory Sync",
+ organizationId: organization.id,
+ });
+ const scim = await requireScimBearerToken(context, token.token);
+ return { organization, token, scim };
+}
+
test("SCIM bearer tokens are hashed, accepted, and revoked", async () => {
await withContext(async (context) => {
- const created = await createScimBearerToken(context, { name: "Directory Sync" });
+ const organization = await createOrganization(context);
+ const created = await createScimBearerToken(context, {
+ name: "Directory Sync",
+ organizationId: organization.id,
+ });
assert.ok(created.id);
assert.ok(created.token.startsWith("da_scim_"));
assert.notEqual(created.tokenPrefix, created.token);
const auth = await requireScimBearerToken(context, created.token);
- assert.equal(auth.id, created.id);
+ assert.equal(auth.tokenId, created.id);
+ assert.equal(auth.organizationId, organization.id);
await assert.rejects(
() => requireScimBearerToken(context, "da_scim_wrong"),
@@ -85,10 +119,35 @@ test("SCIM bearer tokens are hashed, accepted, and revoked", async () => {
});
});
+test("SCIM bearer token revocation can be constrained to an organization", async () => {
+ await withContext(async (context) => {
+ const first = await createScimAuth(context);
+ const second = await createScimAuth(context);
+
+ await assert.rejects(
+ () => revokeScimBearerTokenForOrganization(context, second.organization.id, first.token.id),
+ /SCIM token not found/
+ );
+
+ await revokeScimBearerTokenForOrganization(context, first.organization.id, first.token.id);
+
+ await assert.rejects(
+ () => requireScimBearerToken(context, first.token.token),
+ (error: unknown) => error instanceof UnauthorizedError
+ );
+ assert.equal(
+ (await requireScimBearerToken(context, second.token.token)).tokenId,
+ second.token.id
+ );
+ });
+});
+
test("SCIM bearer tokens reject expiry and list inputs reject malformed filters while capping pagination", async () => {
await withContext(async (context) => {
+ const { scim, organization } = await createScimAuth(context);
const expired = await createScimBearerToken(context, {
name: "Expired",
+ organizationId: organization.id,
expiresAt: new Date("2000-01-01T00:00:00.000Z"),
});
await assert.rejects(
@@ -97,18 +156,18 @@ test("SCIM bearer tokens reject expiry and list inputs reject malformed filters
);
for (let index = 0; index < 105; index += 1) {
- await createScimUser(context, {
+ await createScimUser(context, scim, {
userName: `page-${index}@example.com`,
displayName: `Page ${index}`,
});
}
await assert.rejects(
- () => listScimUsers(context, { filter: 'userName sw "page"' }),
+ () => listScimUsers(context, scim, { filter: 'userName sw "page"' }),
/Unsupported SCIM filter/
);
- const listed = await listScimUsers(context, { startIndex: 1, count: 10_000 });
+ const listed = await listScimUsers(context, scim, { startIndex: 1, count: 10_000 });
assert.equal(listed.totalResults, 105);
assert.equal(listed.itemsPerPage, 100);
assert.equal(listed.Resources.length, 100);
@@ -117,7 +176,8 @@ test("SCIM bearer tokens reject expiry and list inputs reject malformed filters
test("SCIM user conformance accepts common IdP payload shapes", async () => {
await withContext(async (context) => {
- const oktaUser = await createScimUser(context, {
+ const { scim } = await createScimAuth(context);
+ const oktaUser = await createScimUser(context, scim, {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
externalId: "00u123",
userName: "okta@example.com",
@@ -125,7 +185,7 @@ test("SCIM user conformance accepts common IdP payload shapes", async () => {
emails: [{ value: "okta@example.com", primary: true, type: "work" }],
active: true,
} as never);
- const azureUser = await createScimUser(context, {
+ const azureUser = await createScimUser(context, scim, {
schemas: [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
@@ -150,7 +210,8 @@ test("SCIM user conformance accepts common IdP payload shapes", async () => {
test("SCIM users support create, get, list, filter, patch, and deactivation revocation", async () => {
await withContext(async (context) => {
- const user = await createScimUser(context, {
+ const { scim, organization } = await createScimAuth(context);
+ const user = await createScimUser(context, scim, {
externalId: "external-1",
userName: "ada@example.com",
name: { formatted: "Ada Lovelace" },
@@ -161,10 +222,10 @@ test("SCIM users support create, get, list, filter, patch, and deactivation revo
assert.equal(user.userName, "ada@example.com");
assert.equal(user.active, true);
- const fetched = await getScimUser(context, user.id);
+ const fetched = await getScimUser(context, scim, user.id);
assert.equal(fetched.id, user.id);
- const listed = await listScimUsers(context, {
+ const listed = await listScimUsers(context, scim, {
filter: 'externalId eq "external-1"',
startIndex: 1,
count: 10,
@@ -172,7 +233,7 @@ test("SCIM users support create, get, list, filter, patch, and deactivation revo
assert.equal(listed.totalResults, 1);
assert.equal(listed.Resources[0]?.id, user.id);
- const patched = await patchScimUser(context, user.id, [
+ const patched = await patchScimUser(context, scim, user.id, [
{ op: "replace", path: "displayName", value: "Countess Lovelace" },
{ op: "replace", path: "active", value: false },
]);
@@ -184,12 +245,12 @@ test("SCIM users support create, get, list, filter, patch, and deactivation revo
cohort: "user",
userSub: user.id,
expiresAt: new Date("2099-01-01T00:00:00.000Z"),
- data: { sub: user.id },
+ data: { sub: user.id, organizationId: organization.id },
refreshToken: "refresh-hash",
refreshTokenExpiresAt: new Date("2099-01-01T00:00:00.000Z"),
});
- await deactivateScimUser(context, user.id);
+ await deactivateScimUser(context, scim, user.id);
const remaining = await context.db
.select({ id: sessions.id })
@@ -198,7 +259,7 @@ test("SCIM users support create, get, list, filter, patch, and deactivation revo
assert.equal(remaining.length, 0);
await assert.rejects(
- () => createSession(context, "user", { sub: user.id }),
+ () => createSession(context, "user", { sub: user.id, organizationId: organization.id }),
(error: unknown) => error instanceof UnauthorizedError
);
});
@@ -206,17 +267,18 @@ test("SCIM users support create, get, list, filter, patch, and deactivation revo
test("SCIM deactivation invalidates outstanding auth codes and pending auth for that user", async () => {
await withContext(async (context) => {
+ const { scim, organization } = await createScimAuth(context);
await createClient(context, {
clientId: "client-id",
name: "Client",
type: "public",
redirectUris: ["https://client.example/callback"],
});
- const deprovisioned = await createScimUser(context, {
+ const deprovisioned = await createScimUser(context, scim, {
userName: "deprovisioned@example.com",
displayName: "Deprovisioned User",
});
- const active = await createScimUser(context, {
+ const active = await createScimUser(context, scim, {
userName: "active@example.com",
displayName: "Active User",
});
@@ -225,6 +287,7 @@ test("SCIM deactivation invalidates outstanding auth codes and pending auth for
code: "deprovisioned-code",
clientId: "client-id",
userSub: deprovisioned.id,
+ organizationId: organization.id,
redirectUri: "https://client.example/callback",
scope: "openid",
expiresAt: new Date("2099-01-01T00:00:00.000Z"),
@@ -233,6 +296,7 @@ test("SCIM deactivation invalidates outstanding auth codes and pending auth for
code: "active-code",
clientId: "client-id",
userSub: active.id,
+ organizationId: organization.id,
redirectUri: "https://client.example/callback",
scope: "openid",
expiresAt: new Date("2099-01-01T00:00:00.000Z"),
@@ -241,6 +305,7 @@ test("SCIM deactivation invalidates outstanding auth codes and pending auth for
requestId: "deprovisioned-request",
clientId: "client-id",
userSub: deprovisioned.id,
+ organizationId: organization.id,
redirectUri: "https://client.example/callback",
scope: "openid",
origin: "https://auth.example.com",
@@ -250,13 +315,14 @@ test("SCIM deactivation invalidates outstanding auth codes and pending auth for
requestId: "active-request",
clientId: "client-id",
userSub: active.id,
+ organizationId: organization.id,
redirectUri: "https://client.example/callback",
scope: "openid",
origin: "https://auth.example.com",
expiresAt: new Date("2099-01-01T00:00:00.000Z"),
});
- await deactivateScimUser(context, deprovisioned.id);
+ await deactivateScimUser(context, scim, deprovisioned.id);
assert.equal(await getAuthCode(context, "deprovisioned-code"), null);
assert.equal((await getAuthCode(context, "active-code"))?.code, "active-code");
@@ -283,13 +349,14 @@ test("SCIM deactivation invalidates outstanding auth codes and pending auth for
test("inactive SCIM auth codes are not returned for token redemption defense", async () => {
await withContext(async (context) => {
+ const { scim, organization } = await createScimAuth(context);
await createClient(context, {
clientId: "client-id",
name: "Client",
type: "public",
redirectUris: ["https://client.example/callback"],
});
- const user = await createScimUser(context, {
+ const user = await createScimUser(context, scim, {
userName: "inactive-code@example.com",
displayName: "Inactive Code",
});
@@ -297,6 +364,7 @@ test("inactive SCIM auth codes are not returned for token redemption defense", a
code: "inactive-code",
clientId: "client-id",
userSub: user.id,
+ organizationId: organization.id,
redirectUri: "https://client.example/callback",
scope: "openid",
expiresAt: new Date("2099-01-01T00:00:00.000Z"),
@@ -312,20 +380,21 @@ test("inactive SCIM auth codes are not returned for token redemption defense", a
test("SCIM groups support membership patching", async () => {
await withContext(async (context) => {
- const user = await createScimUser(context, {
+ const { scim } = await createScimAuth(context);
+ const user = await createScimUser(context, scim, {
externalId: "external-2",
userName: "grace@example.com",
displayName: "Grace Hopper",
});
- const group = await createScimGroup(context, {
+ const group = await createScimGroup(context, scim, {
externalId: "group-1",
displayName: "Engineers",
});
assert.equal(group.members.length, 0);
- const patched = await patchScimGroup(context, group.id, [
+ const patched = await patchScimGroup(context, scim, group.id, [
{ op: "add", path: "members", value: [{ value: user.id }] },
]);
assert.equal(patched.members.length, 1);
@@ -339,15 +408,106 @@ test("SCIM groups support membership patching", async () => {
});
});
+test("SCIM connections cannot read users from another organization", async () => {
+ await withContext(async (context) => {
+ const first = await createScimAuth(context);
+ const second = await createScimAuth(context);
+ const user = await createScimUser(context, first.scim, {
+ externalId: "boundary-user",
+ userName: "boundary@example.com",
+ displayName: "Boundary User",
+ });
+ await assert.rejects(() => getScimUser(context, second.scim, user.id), /SCIM user not found/);
+ const secondUser = await createScimUser(context, second.scim, {
+ externalId: "boundary-user-second",
+ userName: "boundary@example.com",
+ displayName: "Boundary User Second",
+ });
+
+ const firstList = await listScimUsers(context, first.scim, {
+ filter: 'externalId eq "boundary-user"',
+ });
+ const secondList = await listScimUsers(context, second.scim, {
+ filter: 'externalId eq "boundary-user"',
+ });
+
+ assert.equal(firstList.totalResults, 1);
+ assert.equal(secondList.totalResults, 0);
+ assert.equal(secondUser.id, user.id);
+ });
+});
+
+test("SCIM deprovisioning preserves unrelated organization membership and sessions", async () => {
+ await withContext(async (context) => {
+ const { scim, organization } = await createScimAuth(context);
+ const unrelated = await createOrganization(context);
+ const user = await createScimUser(context, scim, {
+ userName: "multi-org@example.com",
+ displayName: "Multi Org",
+ });
+ await context.db.insert(organizationMembers).values({
+ organizationId: unrelated.id,
+ userSub: user.id,
+ status: "active",
+ });
+ await context.db.insert(sessions).values([
+ {
+ id: "connection-session",
+ cohort: "user",
+ userSub: user.id,
+ expiresAt: new Date("2099-01-01T00:00:00.000Z"),
+ data: { sub: user.id, organizationId: organization.id },
+ },
+ {
+ id: "unrelated-session",
+ cohort: "user",
+ userSub: user.id,
+ expiresAt: new Date("2099-01-01T00:00:00.000Z"),
+ data: { sub: user.id, organizationId: unrelated.id },
+ },
+ ]);
+
+ await deactivateScimUser(context, scim, user.id);
+
+ const memberships = await context.db
+ .select()
+ .from(organizationMembers)
+ .where(eq(organizationMembers.userSub, user.id))
+ .orderBy(organizationMembers.organizationId);
+ const remainingSessions = await context.db
+ .select({ id: sessions.id })
+ .from(sessions)
+ .where(eq(sessions.userSub, user.id));
+
+ assert.equal(
+ memberships.find((membership) => membership.organizationId === organization.id)?.status,
+ "suspended"
+ );
+ assert.equal(
+ memberships.find((membership) => membership.organizationId === unrelated.id)?.status,
+ "active"
+ );
+ assert.deepEqual(
+ remainingSessions.map((session) => session.id),
+ ["unrelated-session"]
+ );
+ });
+});
+
test("SCIM deprovision action delete removes local user and returns a tombstone response", async () => {
await withContext(async (context) => {
- await setSetting(context, "users.scim.deprovision_action", "delete");
- const user = await createScimUser(context, {
+ const { scim } = await createScimAuth(context);
+ const deleteScim = {
+ ...scim,
+ deprovisionAction: "delete_user",
+ deleteUserSafety: "allow_delete",
+ };
+ const user = await createScimUser(context, deleteScim, {
userName: "deleted@example.com",
displayName: "Deleted User",
});
- const deleted = await deactivateScimUser(context, user.id);
+ const deleted = await deactivateScimUser(context, deleteScim, user.id);
const userRows = await context.db.select().from(users).where(eq(users.sub, user.id));
const scimRows = await context.db
.select()
@@ -361,23 +521,75 @@ test("SCIM deprovision action delete removes local user and returns a tombstone
});
});
+test("SCIM delete-user deprovisioning fails closed when unrelated memberships exist", async () => {
+ await withContext(async (context) => {
+ const { scim } = await createScimAuth(context);
+ const unrelated = await createOrganization(context);
+ const deleteScim = {
+ ...scim,
+ deprovisionAction: "delete_user",
+ deleteUserSafety: "fail_closed",
+ };
+ const user = await createScimUser(context, deleteScim, {
+ userName: "delete-safe@example.com",
+ displayName: "Delete Safe",
+ });
+ await context.db.insert(organizationMembers).values({
+ organizationId: unrelated.id,
+ userSub: user.id,
+ status: "active",
+ });
+
+ const deprovisioned = await deactivateScimUser(context, deleteScim, user.id);
+ const userRows = await context.db.select().from(users).where(eq(users.sub, user.id));
+ const unrelatedMembership = await context.db.query.organizationMembers.findFirst({
+ where: and(
+ eq(organizationMembers.organizationId, unrelated.id),
+ eq(organizationMembers.userSub, user.id)
+ ),
+ });
+
+ assert.equal(deprovisioned.active, false);
+ assert.equal(userRows.length, 1);
+ assert.equal(unrelatedMembership?.status, "active");
+ });
+});
+
test("SCIM group mappings synchronize organization membership and roles", async () => {
await withContext(async (context) => {
- const user = await createScimUser(context, {
+ const { scim } = await createScimAuth(
+ context,
+ "11111111-1111-4111-8111-111111111111",
+ "engineering"
+ );
+ const user = await createScimUser(context, scim, {
userName: "mapped@example.com",
displayName: "Mapped User",
});
- await context.db.insert(organizations).values({
- id: "11111111-1111-4111-8111-111111111111",
- slug: "engineering",
- name: "Engineering",
- });
await context.db.insert(roles).values({
id: "22222222-2222-4222-8222-222222222222",
key: "engineer",
name: "Engineer",
system: true,
});
+ await context.db.insert(users).values({
+ sub: "manual-user",
+ email: "manual@example.com",
+ opaqueLoginIdentity: "manual@example.com",
+ name: "Manual User",
+ });
+ const [manualMembership] = await context.db
+ .insert(organizationMembers)
+ .values({
+ organizationId: "11111111-1111-4111-8111-111111111111",
+ userSub: "manual-user",
+ status: "active",
+ })
+ .returning();
+ await context.db.insert(organizationMemberRoles).values({
+ organizationMemberId: manualMembership?.id || "",
+ roleId: "22222222-2222-4222-8222-222222222222",
+ });
await setSetting(context, "users.scim.group_role_mappings", {
mappings: [
{
@@ -388,7 +600,7 @@ test("SCIM group mappings synchronize organization membership and roles", async
],
});
- const group = await createScimGroup(context, {
+ const group = await createScimGroup(context, scim, {
displayName: "Engineers",
members: [{ value: user.id }],
});
@@ -404,10 +616,9 @@ test("SCIM group mappings synchronize organization membership and roles", async
.select()
.from(organizationMemberRoles)
.where(eq(organizationMemberRoles.organizationMemberId, membership?.id || ""));
- assert.equal(assignedRoles.length, 1);
- assert.equal(assignedRoles[0]?.roleId, "22222222-2222-4222-8222-222222222222");
+ assert.ok(assignedRoles.some((role) => role.roleId === "22222222-2222-4222-8222-222222222222"));
- await patchScimGroup(context, group.id, [{ op: "remove", path: "members", value: [] }]);
+ await patchScimGroup(context, scim, group.id, [{ op: "remove", path: "members", value: [] }]);
const updatedMembership = await context.db.query.organizationMembers.findFirst({
where: eq(organizationMembers.id, membership?.id || ""),
@@ -416,18 +627,31 @@ test("SCIM group mappings synchronize organization membership and roles", async
.select()
.from(organizationMemberRoles)
.where(eq(organizationMemberRoles.organizationMemberId, membership?.id || ""));
+ const manualAfterSync = await context.db.query.organizationMembers.findFirst({
+ where: eq(organizationMembers.id, manualMembership?.id || ""),
+ });
+ const manualRolesAfterSync = await context.db
+ .select()
+ .from(organizationMemberRoles)
+ .where(eq(organizationMemberRoles.organizationMemberId, manualMembership?.id || ""));
assert.equal(updatedMembership?.status, "suspended");
- assert.equal(remainingRoles.length, 0);
+ assert.equal(
+ remainingRoles.some((role) => role.roleId === "22222222-2222-4222-8222-222222222222"),
+ false
+ );
+ assert.equal(manualAfterSync?.status, "active");
+ assert.equal(manualRolesAfterSync.length, 1);
});
});
test("SCIM unknown group reject policy rejects unmapped groups", async () => {
await withContext(async (context) => {
+ const { scim } = await createScimAuth(context);
await setSetting(context, "users.scim.unknown_group_policy", "reject");
await assert.rejects(
() =>
- createScimGroup(context, {
+ createScimGroup(context, scim, {
displayName: "Unmapped",
}),
(error: unknown) => error instanceof Error && error.message === "SCIM group has no mapping"
diff --git a/packages/api/src/models/scim.ts b/packages/api/src/models/scim.ts
index 17125ef2..ea8e81e2 100644
--- a/packages/api/src/models/scim.ts
+++ b/packages/api/src/models/scim.ts
@@ -1,10 +1,13 @@
-import { and, asc, count, eq, gt, ilike, inArray, isNull, or } from "drizzle-orm";
+import { and, asc, count, eq, gt, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import {
+ authCodes,
organizationMemberRoles,
organizationMembers,
organizations,
+ pendingAuth,
roles,
scimBearerTokens,
+ scimConnections,
scimGroupMembers,
scimGroups,
scimUsers,
@@ -14,10 +17,15 @@ import {
import { ConflictError, NotFoundError, UnauthorizedError, ValidationError } from "../errors.ts";
import type { Context } from "../types.ts";
import { constantTimeCompare, generateRandomString, sha256Base64Url } from "../utils/crypto.ts";
-import { deleteAuthCodesForUser } from "./authCodes.ts";
-import { deletePendingAuthForUser } from "./authorize.ts";
import { getStringSetting } from "./scimPolicy.ts";
-import { createUser as createLocalUser } from "./users.ts";
+
+export type ScimConnectionContext = {
+ id: string;
+ tokenId: string;
+ organizationId: string;
+ deprovisionAction: string;
+ deleteUserSafety: string;
+};
type ScimUserInput = {
externalId?: string | null;
@@ -134,34 +142,43 @@ function parseSimpleFilter(filter?: string | null) {
};
}
-function userFilterCondition(filter?: string | null) {
+function userFilterCondition(scim: ScimConnectionContext, filter?: string | null) {
const parsed = parseSimpleFilter(filter);
- if (!parsed) return undefined;
- if (parsed.attr === "id") return eq(scimUsers.userSub, parsed.value);
+ const scope = eq(scimUsers.connectionId, scim.id);
+ if (!parsed) return scope;
+ if (parsed.attr === "id") return and(scope, eq(scimUsers.userSub, parsed.value));
if (parsed.attr === "userName") {
- return parsed.op === "co"
- ? ilike(scimUsers.userName, `%${parsed.value}%`)
- : eq(scimUsers.userName, parsed.value);
+ const match =
+ parsed.op === "co"
+ ? ilike(scimUsers.userName, `%${parsed.value}%`)
+ : eq(scimUsers.userName, parsed.value);
+ return and(scope, match);
}
- if (parsed.attr === "externalId") return eq(scimUsers.externalId, parsed.value);
+ if (parsed.attr === "externalId") return and(scope, eq(scimUsers.externalId, parsed.value));
if (parsed.attr === "displayName") {
- return parsed.op === "co"
- ? ilike(scimUsers.displayName, `%${parsed.value}%`)
- : eq(scimUsers.displayName, parsed.value);
+ const match =
+ parsed.op === "co"
+ ? ilike(scimUsers.displayName, `%${parsed.value}%`)
+ : eq(scimUsers.displayName, parsed.value);
+ return and(scope, match);
}
- if (parsed.attr === "active") return eq(scimUsers.active, parsed.value.toLowerCase() === "true");
+ if (parsed.attr === "active")
+ return and(scope, eq(scimUsers.active, parsed.value.toLowerCase() === "true"));
throw new ValidationError("Unsupported SCIM filter");
}
-function groupFilterCondition(filter?: string | null) {
+function groupFilterCondition(scim: ScimConnectionContext, filter?: string | null) {
const parsed = parseSimpleFilter(filter);
- if (!parsed) return undefined;
- if (parsed.attr === "id") return eq(scimGroups.id, parsed.value);
- if (parsed.attr === "externalId") return eq(scimGroups.externalId, parsed.value);
+ const scope = eq(scimGroups.connectionId, scim.id);
+ if (!parsed) return scope;
+ if (parsed.attr === "id") return and(scope, eq(scimGroups.id, parsed.value));
+ if (parsed.attr === "externalId") return and(scope, eq(scimGroups.externalId, parsed.value));
if (parsed.attr === "displayName") {
- return parsed.op === "co"
- ? ilike(scimGroups.displayName, `%${parsed.value}%`)
- : eq(scimGroups.displayName, parsed.value);
+ const match =
+ parsed.op === "co"
+ ? ilike(scimGroups.displayName, `%${parsed.value}%`)
+ : eq(scimGroups.displayName, parsed.value);
+ return and(scope, match);
}
throw new ValidationError("Unsupported SCIM filter");
}
@@ -196,7 +213,7 @@ function toScimUser(row: {
};
}
-async function findScimUserRow(context: Context, userSub: string) {
+async function findScimUserRow(context: Context, scim: ScimConnectionContext, userSub: string) {
const [row] = await context.db
.select({
userSub: scimUsers.userSub,
@@ -211,22 +228,42 @@ async function findScimUserRow(context: Context, userSub: string) {
})
.from(scimUsers)
.innerJoin(users, eq(users.sub, scimUsers.userSub))
- .where(eq(scimUsers.userSub, userSub));
+ .where(and(eq(scimUsers.connectionId, scim.id), eq(scimUsers.userSub, userSub)));
return row || null;
}
export async function createScimBearerToken(
context: Context,
- input: { name: string; createdByAdminId?: string | null; expiresAt?: Date | null }
+ input: {
+ name: string;
+ organizationId: string;
+ connectionId?: string | null;
+ connectionName?: string | null;
+ createdByAdminId?: string | null;
+ expiresAt?: Date | null;
+ }
) {
const name = input.name.trim();
if (!name) throw new ValidationError("Token name is required");
+ const organizationId = input.organizationId.trim();
+ const organization = await context.db.query.organizations.findFirst({
+ where: eq(organizations.id, organizationId),
+ });
+ if (!organization) throw new ValidationError("Organization is required");
+ const connection = input.connectionId
+ ? await getScimConnectionForOrganization(context, input.connectionId, organizationId)
+ : await createScimConnection(context, {
+ organizationId,
+ name: input.connectionName || name,
+ });
const token = `da_scim_${generateRandomString(32)}`;
const tokenHash = sha256Base64Url(token);
const tokenPrefix = token.slice(0, 16);
const [row] = await context.db
.insert(scimBearerTokens)
.values({
+ connectionId: connection.id,
+ organizationId,
name,
tokenHash,
tokenPrefix,
@@ -238,10 +275,128 @@ export async function createScimBearerToken(
return { ...row, token };
}
-export async function listScimBearerTokens(context: Context) {
+export async function createScimConnection(
+ context: Context,
+ input: { organizationId: string; name: string; deprovisionAction?: string | null }
+) {
+ const organizationId = input.organizationId.trim();
+ const organization = await context.db.query.organizations.findFirst({
+ where: eq(organizations.id, organizationId),
+ });
+ if (!organization) throw new ValidationError("Organization is required");
+ const name = input.name.trim();
+ if (!name) throw new ValidationError("Connection name is required");
+ const [row] = await context.db
+ .insert(scimConnections)
+ .values({
+ organizationId,
+ name,
+ deprovisionAction: input.deprovisionAction || "suspend_membership",
+ })
+ .returning();
+ if (!row) throw new ValidationError("Unable to create SCIM connection");
+ return row;
+}
+
+async function getScimConnectionForOrganization(
+ context: Context,
+ connectionId: string,
+ organizationId: string
+) {
+ const connection = await context.db.query.scimConnections.findFirst({
+ where: and(
+ eq(scimConnections.id, connectionId),
+ eq(scimConnections.organizationId, organizationId)
+ ),
+ });
+ if (!connection) throw new ValidationError("SCIM connection not found");
+ if (!connection.enabled) throw new ValidationError("SCIM connection is disabled");
+ return connection;
+}
+
+export async function listScimConnectionsForOrganization(context: Context, organizationId: string) {
+ return await context.db
+ .select()
+ .from(scimConnections)
+ .where(eq(scimConnections.organizationId, organizationId))
+ .orderBy(asc(scimConnections.createdAt));
+}
+
+export async function getScimConnectionForOrg(
+ context: Context,
+ organizationId: string,
+ connectionId: string
+) {
+ const connection = await context.db.query.scimConnections.findFirst({
+ where: and(
+ eq(scimConnections.id, connectionId),
+ eq(scimConnections.organizationId, organizationId)
+ ),
+ });
+ if (!connection) throw new NotFoundError("SCIM connection not found");
+ return connection;
+}
+
+export async function updateScimConnectionForOrg(
+ context: Context,
+ organizationId: string,
+ connectionId: string,
+ updates: {
+ name?: string;
+ enabled?: boolean;
+ deprovisionAction?: string;
+ deleteUserSafety?: string;
+ }
+) {
+ await getScimConnectionForOrg(context, organizationId, connectionId);
+ const patch: Partial = { updatedAt: new Date() };
+ if (typeof updates.name === "string") {
+ const name = updates.name.trim();
+ if (!name) throw new ValidationError("Connection name is required");
+ patch.name = name;
+ }
+ if (typeof updates.enabled === "boolean") patch.enabled = updates.enabled;
+ if (typeof updates.deprovisionAction === "string")
+ patch.deprovisionAction = updates.deprovisionAction;
+ if (typeof updates.deleteUserSafety === "string")
+ patch.deleteUserSafety = updates.deleteUserSafety;
+ const [row] = await context.db
+ .update(scimConnections)
+ .set(patch)
+ .where(
+ and(eq(scimConnections.id, connectionId), eq(scimConnections.organizationId, organizationId))
+ )
+ .returning();
+ if (!row) throw new NotFoundError("SCIM connection not found");
+ return row;
+}
+
+export async function deleteScimConnectionForOrg(
+ context: Context,
+ organizationId: string,
+ connectionId: string
+) {
+ const [row] = await context.db
+ .delete(scimConnections)
+ .where(
+ and(eq(scimConnections.id, connectionId), eq(scimConnections.organizationId, organizationId))
+ )
+ .returning();
+ if (!row) throw new NotFoundError("SCIM connection not found");
+ return { success: true as const };
+}
+
+export async function listScimBearerTokensForConnection(
+ context: Context,
+ organizationId: string,
+ connectionId: string
+) {
+ await getScimConnectionForOrg(context, organizationId, connectionId);
return await context.db
.select({
id: scimBearerTokens.id,
+ connectionId: scimBearerTokens.connectionId,
+ organizationId: scimBearerTokens.organizationId,
name: scimBearerTokens.name,
tokenPrefix: scimBearerTokens.tokenPrefix,
createdByAdminId: scimBearerTokens.createdByAdminId,
@@ -251,19 +406,111 @@ export async function listScimBearerTokens(context: Context) {
revokedAt: scimBearerTokens.revokedAt,
})
.from(scimBearerTokens)
+ .where(
+ and(
+ eq(scimBearerTokens.organizationId, organizationId),
+ eq(scimBearerTokens.connectionId, connectionId)
+ )
+ )
.orderBy(asc(scimBearerTokens.createdAt));
}
-export async function revokeScimBearerToken(context: Context, id: string) {
+export async function createScimBearerTokenForConnection(
+ context: Context,
+ organizationId: string,
+ connectionId: string,
+ input: { name?: string | null; expiresAt?: Date | null }
+) {
+ const connection = await getScimConnectionForOrg(context, organizationId, connectionId);
+ const name = (input.name || connection.name).trim();
+ if (!name) throw new ValidationError("Token name is required");
+ const token = `da_scim_${generateRandomString(32)}`;
+ const tokenHash = sha256Base64Url(token);
+ const tokenPrefix = token.slice(0, 16);
+ const [row] = await context.db
+ .insert(scimBearerTokens)
+ .values({
+ connectionId: connection.id,
+ organizationId,
+ name,
+ tokenHash,
+ tokenPrefix,
+ createdByAdminId: null,
+ expiresAt: input.expiresAt || null,
+ })
+ .returning();
+ if (!row) throw new ValidationError("Unable to create SCIM token");
+ return { ...row, token };
+}
+
+export async function revokeScimBearerTokenForConnection(
+ context: Context,
+ organizationId: string,
+ connectionId: string,
+ tokenId: string
+) {
const [row] = await context.db
.update(scimBearerTokens)
.set({ revokedAt: new Date() })
- .where(eq(scimBearerTokens.id, id))
+ .where(
+ and(
+ eq(scimBearerTokens.id, tokenId),
+ eq(scimBearerTokens.organizationId, organizationId),
+ eq(scimBearerTokens.connectionId, connectionId)
+ )
+ )
+ .returning();
+ if (!row) throw new NotFoundError("SCIM token not found");
+ return { success: true as const };
+}
+
+export async function listScimBearerTokens(context: Context, organizationId?: string) {
+ const condition = organizationId
+ ? eq(scimBearerTokens.organizationId, organizationId)
+ : undefined;
+ const query = context.db
+ .select({
+ id: scimBearerTokens.id,
+ connectionId: scimBearerTokens.connectionId,
+ organizationId: scimBearerTokens.organizationId,
+ organizationSlug: organizations.slug,
+ organizationName: organizations.name,
+ name: scimBearerTokens.name,
+ tokenPrefix: scimBearerTokens.tokenPrefix,
+ createdByAdminId: scimBearerTokens.createdByAdminId,
+ createdAt: scimBearerTokens.createdAt,
+ lastUsedAt: scimBearerTokens.lastUsedAt,
+ expiresAt: scimBearerTokens.expiresAt,
+ revokedAt: scimBearerTokens.revokedAt,
+ })
+ .from(scimBearerTokens)
+ .leftJoin(organizations, eq(organizations.id, scimBearerTokens.organizationId));
+ return await (condition ? query.where(condition) : query).orderBy(
+ asc(scimBearerTokens.createdAt)
+ );
+}
+
+export async function revokeScimBearerToken(context: Context, id: string, organizationId?: string) {
+ const condition = organizationId
+ ? and(eq(scimBearerTokens.id, id), eq(scimBearerTokens.organizationId, organizationId))
+ : eq(scimBearerTokens.id, id);
+ const [row] = await context.db
+ .update(scimBearerTokens)
+ .set({ revokedAt: new Date() })
+ .where(condition)
.returning();
if (!row) throw new NotFoundError("SCIM token not found");
return { success: true };
}
+export async function revokeScimBearerTokenForOrganization(
+ context: Context,
+ organizationId: string,
+ id: string
+) {
+ return await revokeScimBearerToken(context, id, organizationId);
+}
+
export async function requireScimBearerToken(context: Context, token: string | null | undefined) {
if (!token) throw new UnauthorizedError("SCIM bearer token required");
const tokenHash = sha256Base64Url(token);
@@ -272,24 +519,41 @@ export async function requireScimBearerToken(context: Context, token: string | n
.select({
id: scimBearerTokens.id,
tokenHash: scimBearerTokens.tokenHash,
+ connectionId: scimBearerTokens.connectionId,
+ organizationId: scimBearerTokens.organizationId,
+ deprovisionAction: scimConnections.deprovisionAction,
+ deleteUserSafety: scimConnections.deleteUserSafety,
})
.from(scimBearerTokens)
+ .innerJoin(scimConnections, eq(scimConnections.id, scimBearerTokens.connectionId))
.where(
and(
+ eq(scimConnections.enabled, true),
isNull(scimBearerTokens.revokedAt),
or(isNull(scimBearerTokens.expiresAt), gt(scimBearerTokens.expiresAt, now))
)
);
const match = candidates.find((candidate) => constantTimeCompare(candidate.tokenHash, tokenHash));
- if (!match) throw new UnauthorizedError("Invalid SCIM bearer token");
+ if (!match || !match.connectionId || !match.organizationId)
+ throw new UnauthorizedError("Invalid SCIM bearer token");
await context.db
.update(scimBearerTokens)
.set({ lastUsedAt: now })
.where(eq(scimBearerTokens.id, match.id));
- return match;
+ return {
+ id: match.connectionId,
+ tokenId: match.id,
+ organizationId: match.organizationId,
+ deprovisionAction: match.deprovisionAction,
+ deleteUserSafety: match.deleteUserSafety,
+ };
}
-export async function createScimUser(context: Context, input: ScimUserInput) {
+export async function createScimUser(
+ context: Context,
+ scim: ScimConnectionContext,
+ input: ScimUserInput
+) {
const userName = resolvedUserName(input);
const email = resolvedEmail(input, userName);
const displayName = resolvedDisplayName(input);
@@ -299,13 +563,25 @@ export async function createScimUser(context: Context, input: ScimUserInput) {
: null;
const existing = await context.db.query.scimUsers.findFirst({
where: externalId
- ? or(eq(scimUsers.externalId, externalId), eq(scimUsers.userName, userName))
- : eq(scimUsers.userName, userName),
+ ? and(
+ eq(scimUsers.connectionId, scim.id),
+ or(eq(scimUsers.externalId, externalId), eq(scimUsers.userName, userName))
+ )
+ : and(eq(scimUsers.connectionId, scim.id), eq(scimUsers.userName, userName)),
});
if (existing) throw new ConflictError("SCIM user already exists");
- const local = await createLocalUser(context, { email, name: displayName || userName });
+ const local = await createScimRootUser(context, { email, name: displayName || userName });
+ const membership = await provisionScimMembership(
+ context,
+ scim,
+ local.sub,
+ input.active !== false
+ );
await context.db.insert(scimUsers).values({
userSub: local.sub,
+ connectionId: scim.id,
+ organizationId: scim.organizationId,
+ organizationMemberId: membership?.id || null,
externalId,
userName,
displayName,
@@ -314,27 +590,50 @@ export async function createScimUser(context: Context, input: ScimUserInput) {
createdAt: new Date(),
updatedAt: new Date(),
});
- if (input.active === false) await revokeUserAuthorizationState(context, local.sub);
- const row = await findScimUserRow(context, local.sub);
+ if (input.active === false)
+ await revokeUserAuthorizationState(context, local.sub, scim.organizationId);
+ const row = await findScimUserRow(context, scim, local.sub);
if (!row) throw new NotFoundError("SCIM user not found");
return toScimUser(row);
}
-export async function getScimUser(context: Context, userSub: string) {
- const row = await findScimUserRow(context, userSub);
+async function createScimRootUser(
+ context: Context,
+ data: { email: string; name?: string | null; sub?: string }
+) {
+ const sub = data.sub || generateRandomString(16);
+ const existing = await context.db.query.users.findFirst({ where: eq(users.email, data.email) });
+ if (existing) {
+ await context.db
+ .update(users)
+ .set({ name: data.name || existing.name })
+ .where(eq(users.sub, existing.sub));
+ return { sub: existing.sub, email: data.email, name: data.name || existing.name };
+ }
+ await context.db.insert(users).values({
+ sub,
+ email: data.email,
+ opaqueLoginIdentity: data.email,
+ name: data.name || null,
+ createdAt: new Date(),
+ });
+ return { sub, email: data.email, name: data.name || null };
+}
+
+export async function getScimUser(context: Context, scim: ScimConnectionContext, userSub: string) {
+ const row = await findScimUserRow(context, scim, userSub);
if (!row) throw new NotFoundError("SCIM user not found");
return toScimUser(row);
}
export async function listScimUsers(
context: Context,
+ scim: ScimConnectionContext,
input: { startIndex?: number; count?: number; filter?: string | null }
) {
const { startIndex, itemsPerPage, offset } = parsePage(input);
- const condition = userFilterCondition(input.filter);
- const totalRows = await (condition
- ? context.db.select({ count: count() }).from(scimUsers).where(condition)
- : context.db.select({ count: count() }).from(scimUsers));
+ const condition = userFilterCondition(scim, input.filter);
+ const totalRows = await context.db.select({ count: count() }).from(scimUsers).where(condition);
const query = context.db
.select({
userSub: scimUsers.userSub,
@@ -349,7 +648,8 @@ export async function listScimUsers(
})
.from(scimUsers)
.innerJoin(users, eq(users.sub, scimUsers.userSub));
- const rows = await (condition ? query.where(condition) : query)
+ const rows = await query
+ .where(condition)
.orderBy(asc(scimUsers.userName))
.limit(itemsPerPage)
.offset(offset);
@@ -361,8 +661,13 @@ export async function listScimUsers(
);
}
-export async function replaceScimUser(context: Context, userSub: string, input: ScimUserInput) {
- await getScimUser(context, userSub);
+export async function replaceScimUser(
+ context: Context,
+ scim: ScimConnectionContext,
+ userSub: string,
+ input: ScimUserInput
+) {
+ await getScimUser(context, scim, userSub);
const userName = resolvedUserName(input);
const email = resolvedEmail(input, userName);
const displayName = resolvedDisplayName(input);
@@ -386,18 +691,26 @@ export async function replaceScimUser(context: Context, userSub: string, input:
raw: input as Record,
updatedAt: new Date(),
})
- .where(eq(scimUsers.userSub, userSub));
+ .where(and(eq(scimUsers.connectionId, scim.id), eq(scimUsers.userSub, userSub)));
});
- if (!active) return await deprovisionScimUser(context, userSub);
- return await getScimUser(context, userSub);
+ if (active) {
+ const membership = await provisionScimMembership(context, scim, userSub, true);
+ await context.db
+ .update(scimUsers)
+ .set({ organizationMemberId: membership?.id || null, updatedAt: new Date() })
+ .where(and(eq(scimUsers.connectionId, scim.id), eq(scimUsers.userSub, userSub)));
+ return await getScimUser(context, scim, userSub);
+ }
+ return await deprovisionScimUser(context, scim, userSub);
}
export async function patchScimUser(
context: Context,
+ scim: ScimConnectionContext,
userSub: string,
operations: ScimPatchOperation[]
) {
- const current = await getScimUser(context, userSub);
+ const current = await getScimUser(context, scim, userSub);
const next: ScimUserInput = {
userName: current.userName,
externalId: current.externalId,
@@ -428,35 +741,139 @@ export async function patchScimUser(
next.name = op === "remove" ? null : (operation.value as ScimUserInput["name"]);
else throw new ValidationError("Unsupported PATCH path");
}
- return await replaceScimUser(context, userSub, next);
+ return await replaceScimUser(context, scim, userSub, next);
}
-export async function deactivateScimUser(context: Context, userSub: string) {
- return await deprovisionScimUser(context, userSub);
+export async function deactivateScimUser(
+ context: Context,
+ scim: ScimConnectionContext,
+ userSub: string
+) {
+ return await deprovisionScimUser(context, scim, userSub);
}
-async function deprovisionScimUser(context: Context, userSub: string) {
- const current = await getScimUser(context, userSub);
+async function deprovisionScimUser(context: Context, scim: ScimConnectionContext, userSub: string) {
+ const current = await getScimUser(context, scim, userSub);
await context.db
.update(scimUsers)
.set({ active: false, updatedAt: new Date() })
- .where(eq(scimUsers.userSub, userSub));
- await revokeUserAuthorizationState(context, userSub);
- const action = await getStringSetting(context, "users.scim.deprovision_action", "suspend");
- if (action === "delete") {
+ .where(and(eq(scimUsers.connectionId, scim.id), eq(scimUsers.userSub, userSub)));
+ await applyScimDeprovisionMembershipPolicy(context, scim, userSub);
+ await revokeUserAuthorizationState(context, userSub, scim.organizationId);
+ if (scim.deprovisionAction === "delete_user" || scim.deprovisionAction === "delete") {
+ const activeMemberships = await context.db
+ .select({ id: organizationMembers.id })
+ .from(organizationMembers)
+ .where(
+ and(eq(organizationMembers.userSub, userSub), eq(organizationMembers.status, "active"))
+ );
+ if (activeMemberships.length > 0 || scim.deleteUserSafety === "fail_closed") {
+ return await getScimUser(context, scim, userSub);
+ }
await context.db.delete(users).where(eq(users.sub, userSub));
return { ...current, active: false };
}
- return await getScimUser(context, userSub);
+ return await getScimUser(context, scim, userSub);
+}
+
+async function revokeUserAuthorizationState(
+ context: Context,
+ userSub: string,
+ organizationId: string
+) {
+ await context.db
+ .delete(sessions)
+ .where(
+ and(
+ eq(sessions.userSub, userSub),
+ sql`${sessions.data}->>'organizationId' = ${organizationId}`
+ )
+ );
+ await context.db
+ .delete(authCodes)
+ .where(and(eq(authCodes.userSub, userSub), eq(authCodes.organizationId, organizationId)));
+ await context.db
+ .delete(pendingAuth)
+ .where(and(eq(pendingAuth.userSub, userSub), eq(pendingAuth.organizationId, organizationId)));
+}
+
+async function provisionScimMembership(
+ context: Context,
+ scim: ScimConnectionContext,
+ userSub: string,
+ active: boolean
+) {
+ const [membership] = await context.db
+ .insert(organizationMembers)
+ .values({
+ organizationId: scim.organizationId,
+ userSub,
+ status: active ? "active" : "suspended",
+ scimConnectionId: scim.id,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .onConflictDoUpdate({
+ target: [organizationMembers.organizationId, organizationMembers.userSub],
+ set: {
+ status: active ? "active" : "suspended",
+ scimConnectionId: scim.id,
+ updatedAt: new Date(),
+ },
+ })
+ .returning();
+ if (!membership) return null;
+ if (active) await assignDefaultMemberRoles(context, scim, membership.id);
+ return membership;
}
-async function revokeUserAuthorizationState(context: Context, userSub: string) {
- await context.db.delete(sessions).where(eq(sessions.userSub, userSub));
- await deleteAuthCodesForUser(context, userSub);
- await deletePendingAuthForUser(context, userSub);
+async function assignDefaultMemberRoles(
+ context: Context,
+ scim: ScimConnectionContext,
+ organizationMemberId: string
+) {
+ const defaultRoles = await context.db
+ .select({ id: roles.id })
+ .from(roles)
+ .where(eq(roles.defaultMember, true));
+ const fallbackRoles =
+ defaultRoles.length > 0
+ ? defaultRoles
+ : await context.db.select({ id: roles.id }).from(roles).where(eq(roles.key, "member"));
+ if (fallbackRoles.length === 0) return;
+ await context.db
+ .insert(organizationMemberRoles)
+ .values(
+ fallbackRoles.map((role) => ({
+ organizationMemberId,
+ roleId: role.id,
+ scimConnectionId: scim.id,
+ }))
+ )
+ .onConflictDoNothing();
+}
+
+async function applyScimDeprovisionMembershipPolicy(
+ context: Context,
+ scim: ScimConnectionContext,
+ userSub: string
+) {
+ const condition = and(
+ eq(organizationMembers.organizationId, scim.organizationId),
+ eq(organizationMembers.userSub, userSub),
+ eq(organizationMembers.scimConnectionId, scim.id)
+ );
+ if (scim.deprovisionAction === "remove_membership") {
+ await context.db.delete(organizationMembers).where(condition);
+ return;
+ }
+ await context.db
+ .update(organizationMembers)
+ .set({ status: "suspended", updatedAt: new Date() })
+ .where(condition);
}
-async function groupMembers(context: Context, groupId: string) {
+async function groupMembers(context: Context, groupId: string, connectionId: string | null) {
return await context.db
.select({
value: scimGroupMembers.userSub,
@@ -465,12 +882,16 @@ async function groupMembers(context: Context, groupId: string) {
})
.from(scimGroupMembers)
.innerJoin(scimUsers, eq(scimUsers.userSub, scimGroupMembers.userSub))
- .where(eq(scimGroupMembers.groupId, groupId))
+ .where(
+ connectionId
+ ? and(eq(scimGroupMembers.groupId, groupId), eq(scimUsers.connectionId, connectionId))
+ : eq(scimGroupMembers.groupId, groupId)
+ )
.orderBy(asc(scimUsers.userName));
}
async function toScimGroup(context: Context, row: typeof scimGroups.$inferSelect) {
- const members = await groupMembers(context, row.id);
+ const members = await groupMembers(context, row.id, row.connectionId);
return {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
id: row.id,
@@ -490,7 +911,11 @@ async function toScimGroup(context: Context, row: typeof scimGroups.$inferSelect
};
}
-export async function createScimGroup(context: Context, input: ScimGroupInput) {
+export async function createScimGroup(
+ context: Context,
+ scim: ScimConnectionContext,
+ input: ScimGroupInput
+) {
const displayName = typeof input.displayName === "string" ? input.displayName.trim() : "";
if (!displayName) throw new ValidationError("displayName is required");
const externalId =
@@ -499,38 +924,48 @@ export async function createScimGroup(context: Context, input: ScimGroupInput) {
: null;
const existing = await context.db.query.scimGroups.findFirst({
where: externalId
- ? or(eq(scimGroups.externalId, externalId), eq(scimGroups.displayName, displayName))
- : eq(scimGroups.displayName, displayName),
+ ? and(
+ eq(scimGroups.connectionId, scim.id),
+ or(eq(scimGroups.externalId, externalId), eq(scimGroups.displayName, displayName))
+ )
+ : and(eq(scimGroups.connectionId, scim.id), eq(scimGroups.displayName, displayName)),
});
if (existing) throw new ConflictError("SCIM group already exists");
const [row] = await context.db
.insert(scimGroups)
- .values({ displayName, externalId, raw: input as Record })
+ .values({
+ connectionId: scim.id,
+ organizationId: scim.organizationId,
+ displayName,
+ externalId,
+ raw: input as Record,
+ })
.returning();
if (!row) throw new ValidationError("Unable to create SCIM group");
- await replaceGroupMembers(context, row.id, input.members || []);
+ await replaceGroupMembers(context, scim, row.id, input.members || []);
return await toScimGroup(context, row);
}
-export async function getScimGroup(context: Context, groupId: string) {
- const row = await context.db.query.scimGroups.findFirst({ where: eq(scimGroups.id, groupId) });
+export async function getScimGroup(context: Context, scim: ScimConnectionContext, groupId: string) {
+ const row = await context.db.query.scimGroups.findFirst({
+ where: and(eq(scimGroups.connectionId, scim.id), eq(scimGroups.id, groupId)),
+ });
if (!row) throw new NotFoundError("SCIM group not found");
return await toScimGroup(context, row);
}
export async function listScimGroups(
context: Context,
+ scim: ScimConnectionContext,
input: { startIndex?: number; count?: number; filter?: string | null }
) {
const { startIndex, itemsPerPage, offset } = parsePage(input);
- const condition = groupFilterCondition(input.filter);
- const totalRows = await (condition
- ? context.db.select({ count: count() }).from(scimGroups).where(condition)
- : context.db.select({ count: count() }).from(scimGroups));
- const rows = await (condition
- ? context.db.select().from(scimGroups).where(condition)
- : context.db.select().from(scimGroups)
- )
+ const condition = groupFilterCondition(scim, input.filter);
+ const totalRows = await context.db.select({ count: count() }).from(scimGroups).where(condition);
+ const rows = await context.db
+ .select()
+ .from(scimGroups)
+ .where(condition)
.orderBy(asc(scimGroups.displayName))
.limit(itemsPerPage)
.offset(offset);
@@ -539,18 +974,23 @@ export async function listScimGroups(
return listResponse(resources, Number(totalRows[0]?.count || 0), startIndex, rows.length);
}
-async function assertScimUsersExist(context: Context, userSubs: string[]) {
+async function assertScimUsersExist(
+ context: Context,
+ scim: ScimConnectionContext,
+ userSubs: string[]
+) {
if (userSubs.length === 0) return;
const rows = await context.db
.select({ userSub: scimUsers.userSub })
.from(scimUsers)
- .where(inArray(scimUsers.userSub, userSubs));
+ .where(and(eq(scimUsers.connectionId, scim.id), inArray(scimUsers.userSub, userSubs)));
if (rows.length !== new Set(userSubs).size)
throw new ValidationError("One or more members not found");
}
async function replaceGroupMembers(
context: Context,
+ scim: ScimConnectionContext,
groupId: string,
members: Array<{ value?: string | null }>
) {
@@ -561,7 +1001,7 @@ async function replaceGroupMembers(
.filter(Boolean)
)
);
- await assertScimUsersExist(context, userSubs);
+ await assertScimUsersExist(context, scim, userSubs);
await context.db.transaction(async (tx) => {
await tx.delete(scimGroupMembers).where(eq(scimGroupMembers.groupId, groupId));
if (userSubs.length > 0) {
@@ -572,15 +1012,16 @@ async function replaceGroupMembers(
}
await tx.update(scimGroups).set({ updatedAt: new Date() }).where(eq(scimGroups.id, groupId));
});
- await syncScimGroupMapping(context, groupId);
+ await syncScimGroupMapping(context, scim, groupId);
}
export async function patchScimGroup(
context: Context,
+ scim: ScimConnectionContext,
groupId: string,
operations: ScimPatchOperation[]
) {
- await getScimGroup(context, groupId);
+ await getScimGroup(context, scim, groupId);
for (const operation of operations) {
const op = (operation.op || "replace").toLowerCase();
const path = operation.path?.toLowerCase();
@@ -597,24 +1038,24 @@ export async function patchScimGroup(
const members = Array.isArray(operation.value)
? (operation.value as Array<{ value?: string | null }>)
: [];
- if (op === "replace") await replaceGroupMembers(context, groupId, members);
+ if (op === "replace") await replaceGroupMembers(context, scim, groupId, members);
else if (op === "add") {
const userSubs = members
.map((member) => (typeof member.value === "string" ? member.value.trim() : ""))
.filter(Boolean);
- await assertScimUsersExist(context, userSubs);
+ await assertScimUsersExist(context, scim, userSubs);
if (userSubs.length > 0) {
await context.db
.insert(scimGroupMembers)
.values(userSubs.map((userSub) => ({ groupId, userSub })))
.onConflictDoNothing();
}
- await syncScimGroupMapping(context, groupId);
+ await syncScimGroupMapping(context, scim, groupId);
} else if (op === "remove") {
const userSubs = members
.map((member) => (typeof member.value === "string" ? member.value.trim() : ""))
.filter(Boolean);
- if (userSubs.length === 0) await replaceGroupMembers(context, groupId, []);
+ if (userSubs.length === 0) await replaceGroupMembers(context, scim, groupId, []);
else {
await context.db
.delete(scimGroupMembers)
@@ -625,7 +1066,7 @@ export async function patchScimGroup(
)
);
}
- await syncScimGroupMapping(context, groupId);
+ await syncScimGroupMapping(context, scim, groupId);
} else throw new ValidationError("Unsupported PATCH op");
await context.db
.update(scimGroups)
@@ -635,41 +1076,68 @@ export async function patchScimGroup(
}
throw new ValidationError("Unsupported PATCH path");
}
- return await getScimGroup(context, groupId);
+ return await getScimGroup(context, scim, groupId);
}
-export async function deleteScimGroup(context: Context, groupId: string) {
- await getScimGroup(context, groupId);
- await removeScimGroupMappingRoles(context, groupId);
+export async function deleteScimGroup(
+ context: Context,
+ scim: ScimConnectionContext,
+ groupId: string
+) {
+ await getScimGroup(context, scim, groupId);
+ await removeScimGroupMappingRoles(context, scim, groupId);
await context.db.delete(scimGroups).where(eq(scimGroups.id, groupId));
return { success: true };
}
-async function syncScimGroupMapping(context: Context, groupId: string) {
- const group = await context.db.query.scimGroups.findFirst({ where: eq(scimGroups.id, groupId) });
+async function syncScimGroupMapping(
+ context: Context,
+ scim: ScimConnectionContext,
+ groupId: string
+) {
+ const group = await context.db.query.scimGroups.findFirst({
+ where: and(eq(scimGroups.connectionId, scim.id), eq(scimGroups.id, groupId)),
+ });
if (!group) throw new NotFoundError("SCIM group not found");
- const mapping = await resolveGroupMapping(context, group);
+ const mapping = await resolveGroupMapping(context, scim, group);
if (!mapping) return;
- const organization = await resolveMappedOrganization(context, group, mapping);
+ const organization = await resolveMappedOrganization(context, scim, mapping);
if (!organization) return;
+ if (organization.id !== scim.organizationId) {
+ throw new ValidationError("SCIM group mapping organization must match connection organization");
+ }
const roleIds = await resolveMappedRoleIds(context, mapping);
const members = await context.db
.select({ userSub: scimGroupMembers.userSub })
.from(scimGroupMembers)
.where(eq(scimGroupMembers.groupId, groupId));
const memberSubs = members.map((member) => member.userSub);
- const memberships = await upsertMappedMemberships(context, organization.id, memberSubs);
+ const memberships = await upsertMappedMemberships(context, scim, organization.id, memberSubs);
if (roleIds.length > 0) {
- await syncMappedRoles(context, organization.id, memberships, roleIds, memberSubs);
+ await syncMappedRoles(
+ context,
+ scim,
+ groupId,
+ organization.id,
+ memberships,
+ roleIds,
+ memberSubs
+ );
}
}
-async function removeScimGroupMappingRoles(context: Context, groupId: string) {
- const group = await context.db.query.scimGroups.findFirst({ where: eq(scimGroups.id, groupId) });
+async function removeScimGroupMappingRoles(
+ context: Context,
+ scim: ScimConnectionContext,
+ groupId: string
+) {
+ const group = await context.db.query.scimGroups.findFirst({
+ where: and(eq(scimGroups.connectionId, scim.id), eq(scimGroups.id, groupId)),
+ });
if (!group) return;
- const mapping = await resolveGroupMapping(context, group);
+ const mapping = await resolveGroupMapping(context, scim, group);
if (!mapping) return;
- const organization = await resolveMappedOrganization(context, group, mapping);
+ const organization = await resolveMappedOrganization(context, scim, mapping);
if (!organization) return;
const roleIds = await resolveMappedRoleIds(context, mapping);
if (roleIds.length === 0) return;
@@ -684,13 +1152,16 @@ async function removeScimGroupMappingRoles(context: Context, groupId: string) {
.where(
and(
inArray(organizationMemberRoles.organizationMemberId, membershipIds),
- inArray(organizationMemberRoles.roleId, roleIds)
+ inArray(organizationMemberRoles.roleId, roleIds),
+ eq(organizationMemberRoles.scimConnectionId, scim.id),
+ eq(organizationMemberRoles.scimGroupId, groupId)
)
);
}
async function resolveGroupMapping(
context: Context,
+ scim: ScimConnectionContext,
group: typeof scimGroups.$inferSelect
): Promise {
const setting = await context.db.query.settings.findFirst({
@@ -713,8 +1184,7 @@ async function resolveGroupMapping(
const policy = await getStringSetting(context, "users.scim.unknown_group_policy", "ignore");
if (policy === "reject") throw new ValidationError("SCIM group has no mapping");
if (policy === "create") {
- const organization = await ensureOrganizationForUnknownScimGroup(context, group);
- return { organization_id: organization.id };
+ return { organization_id: scim.organizationId };
}
return null;
}
@@ -738,7 +1208,7 @@ function mappingMatchesGroup(mapping: ScimGroupMapping, group: typeof scimGroups
async function resolveMappedOrganization(
context: Context,
- group: typeof scimGroups.$inferSelect,
+ scim: ScimConnectionContext,
mapping: ScimGroupMapping
) {
if (typeof mapping.organization_id === "string" && mapping.organization_id.trim()) {
@@ -755,31 +1225,11 @@ async function resolveMappedOrganization(
if (!organization) throw new ValidationError("Mapped organization not found");
return organization;
}
- return await ensureOrganizationForUnknownScimGroup(context, group);
-}
-
-async function ensureOrganizationForUnknownScimGroup(
- context: Context,
- group: typeof scimGroups.$inferSelect
-) {
- const slug = slugify(group.displayName);
- const [created] = await context.db
- .insert(organizations)
- .values({
- slug,
- name: group.displayName,
- forceOtp: false,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .onConflictDoNothing({ target: organizations.slug })
- .returning();
- if (created) return created;
- const existing = await context.db.query.organizations.findFirst({
- where: eq(organizations.slug, slug),
+ const organization = await context.db.query.organizations.findFirst({
+ where: eq(organizations.id, scim.organizationId),
});
- if (!existing) throw new ValidationError("Unable to create mapped organization");
- return existing;
+ if (!organization) throw new ValidationError("Mapped organization not found");
+ return organization;
}
async function resolveMappedRoleIds(context: Context, mapping: ScimGroupMapping) {
@@ -800,6 +1250,7 @@ async function resolveMappedRoleIds(context: Context, mapping: ScimGroupMapping)
async function upsertMappedMemberships(
context: Context,
+ scim: ScimConnectionContext,
organizationId: string,
userSubs: string[]
) {
@@ -811,13 +1262,14 @@ async function upsertMappedMemberships(
organizationId,
userSub,
status: "active" as const,
+ scimConnectionId: scim.id,
createdAt: new Date(),
updatedAt: new Date(),
}))
)
.onConflictDoUpdate({
target: [organizationMembers.organizationId, organizationMembers.userSub],
- set: { status: "active", updatedAt: new Date() },
+ set: { status: "active", scimConnectionId: scim.id, updatedAt: new Date() },
});
}
return await context.db
@@ -828,6 +1280,8 @@ async function upsertMappedMemberships(
async function syncMappedRoles(
context: Context,
+ scim: ScimConnectionContext,
+ groupId: string,
organizationId: string,
memberships: Array<{ id: string; userSub: string }>,
roleIds: string[],
@@ -842,7 +1296,12 @@ async function syncMappedRoles(
.insert(organizationMemberRoles)
.values(
activeMemberships.flatMap((membership) =>
- roleIds.map((roleId) => ({ organizationMemberId: membership.id, roleId }))
+ roleIds.map((roleId) => ({
+ organizationMemberId: membership.id,
+ roleId,
+ scimConnectionId: scim.id,
+ scimGroupId: groupId,
+ }))
)
)
.onConflictDoNothing();
@@ -856,7 +1315,9 @@ async function syncMappedRoles(
.where(
and(
inArray(organizationMemberRoles.organizationMemberId, staleMembershipIds),
- inArray(organizationMemberRoles.roleId, roleIds)
+ inArray(organizationMemberRoles.roleId, roleIds),
+ eq(organizationMemberRoles.scimConnectionId, scim.id),
+ eq(organizationMemberRoles.scimGroupId, groupId)
)
);
await context.db
@@ -865,16 +1326,8 @@ async function syncMappedRoles(
.where(
and(
eq(organizationMembers.organizationId, organizationId),
- inArray(organizationMembers.id, staleMembershipIds)
+ inArray(organizationMembers.id, staleMembershipIds),
+ eq(organizationMembers.scimConnectionId, scim.id)
)
);
}
-
-function slugify(value: string) {
- const slug = value
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9-]+/g, "-")
- .replace(/^-+|-+$/g, "");
- return slug || `scim-${generateRandomString(8).toLowerCase()}`;
-}
diff --git a/packages/api/src/models/scimPolicy.ts b/packages/api/src/models/scimPolicy.ts
index 1f740c69..4b472528 100644
--- a/packages/api/src/models/scimPolicy.ts
+++ b/packages/api/src/models/scimPolicy.ts
@@ -6,12 +6,12 @@ import type { Context } from "../types.ts";
import type { KeyEnvelopeType } from "./keybag.ts";
export async function getScimUserPolicyState(context: Context, sub: string) {
- const row = await context.db.query.scimUsers.findFirst({
+ const rows = await context.db.query.scimUsers.findMany({
where: eq(scimUsers.userSub, sub),
});
return {
- provisioned: !!row,
- active: row?.active === true,
+ provisioned: rows.length > 0,
+ active: rows.length === 0 || rows.some((row) => row.active),
};
}
diff --git a/packages/api/src/models/users.test.ts b/packages/api/src/models/users.test.ts
index d9c6ab08..9a13470e 100644
--- a/packages/api/src/models/users.test.ts
+++ b/packages/api/src/models/users.test.ts
@@ -3,10 +3,11 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
+import { eq } from "drizzle-orm";
import { createPglite } from "../db/pglite.ts";
-import { opaqueRecords, users } from "../db/schema.ts";
+import { opaqueRecords, organizationMembers, organizations, users } from "../db/schema.ts";
import type { Context } from "../types.ts";
-import { getUserOpaqueRecordByEmail } from "./users.ts";
+import { createUser, getUserOpaqueRecordByEmail } from "./users.ts";
function createLogger() {
return {
@@ -49,3 +50,73 @@ test("getUserOpaqueRecordByEmail can find preserved OPAQUE login identity after
fs.rmSync(directory, { recursive: true, force: true });
}
});
+
+test("createUser creates a personal organization when no assignment is provided", async () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-users-create-test-"));
+ const { db, close } = await createPglite(directory);
+ const context = { db, logger: createLogger() } as Context;
+
+ try {
+ const created = await createUser(context, {
+ sub: "admin-created-user",
+ email: "admin-created@example.com",
+ name: "Admin Created",
+ });
+ assert.equal(created.sub, "admin-created-user");
+
+ const organizationRows = await db
+ .select({ id: organizations.id, name: organizations.name })
+ .from(organizations)
+ .where(eq(organizations.createdByUserSub, "admin-created-user"));
+ assert.equal(organizationRows.length, 1);
+ assert.equal(organizationRows[0]?.name, "Admin Created's Personal");
+
+ const membershipRows = await db
+ .select({ id: organizationMembers.id })
+ .from(organizationMembers)
+ .where(eq(organizationMembers.userSub, "admin-created-user"));
+ assert.equal(membershipRows.length, 1);
+ } finally {
+ await close();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test("createUser can assign an existing organization without creating a personal one", async () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-users-assign-test-"));
+ const { db, close } = await createPglite(directory);
+ const context = { db, logger: createLogger() } as Context;
+
+ try {
+ const [organization] = await db
+ .insert(organizations)
+ .values({ slug: "assigned-org", name: "Assigned Org" })
+ .returning();
+ assert.ok(organization);
+
+ await createUser(context, {
+ sub: "assigned-user",
+ email: "assigned@example.com",
+ name: "Assigned User",
+ organizationIds: [organization.id],
+ });
+
+ const createdPersonalRows = await db
+ .select({ id: organizations.id })
+ .from(organizations)
+ .where(eq(organizations.createdByUserSub, "assigned-user"));
+ assert.equal(createdPersonalRows.length, 0);
+
+ const membershipRows = await db
+ .select({ organizationId: organizationMembers.organizationId })
+ .from(organizationMembers)
+ .where(eq(organizationMembers.userSub, "assigned-user"));
+ assert.deepEqual(
+ membershipRows.map((row) => row.organizationId),
+ [organization.id]
+ );
+ } finally {
+ await close();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
diff --git a/packages/api/src/models/users.ts b/packages/api/src/models/users.ts
index 7c3c6ff1..5e59f361 100644
--- a/packages/api/src/models/users.ts
+++ b/packages/api/src/models/users.ts
@@ -10,6 +10,10 @@ import {
} from "../db/schema.ts";
import { ConflictError, NotFoundError, ValidationError } from "../errors.ts";
import type { Context } from "../types.ts";
+import {
+ createActiveMembershipWithDefaultRoles,
+ createPersonalOrganizationForUser,
+} from "./organizations.ts";
async function getAccessMapsForSubs(context: Context, subs: string[]) {
const permissionsByUser = new Map();
@@ -184,7 +188,15 @@ export async function listUsers(
export async function createUser(
context: Context,
- data: { email: string; name?: string; sub?: string }
+ data: {
+ email: string;
+ name?: string;
+ sub?: string;
+ organizationIds?: string[];
+ createPersonalOrganization?: boolean;
+ personalOrganizationName?: string;
+ personalOrganizationSlug?: string;
+ }
) {
const email = typeof data.email === "string" ? data.email.trim() : "";
const name = typeof data.name === "string" ? data.name.trim() : undefined;
@@ -195,6 +207,10 @@ export async function createUser(
const existing = await context.db.query.users.findFirst({ where: eq(users.email, email) });
if (existing) throw new ConflictError("Unable to create user");
const sub = subInput || (await (await import("../utils/crypto.ts")).generateRandomString(16));
+ const organizationIds = Array.from(new Set(data.organizationIds || []));
+ if (organizationIds.length > 0 && data.createPersonalOrganization === true) {
+ throw new ValidationError("Choose organization assignment or personal organization creation");
+ }
await context.db.transaction(async (tx) => {
await tx.insert(users).values({
sub,
@@ -203,27 +219,22 @@ export async function createUser(
name: name || null,
createdAt: new Date(),
});
- const defaultOrg = await tx.query.organizations.findFirst({
- where: eq(organizations.slug, "default"),
- });
- if (defaultOrg) {
- const [membership] = await tx
- .insert(organizationMembers)
- .values({
- organizationId: defaultOrg.id,
- userSub: sub,
- status: "active",
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
- const memberRole = await tx.query.roles.findFirst({ where: eq(roles.key, "member") });
- if (membership && memberRole) {
- await tx
- .insert(organizationMemberRoles)
- .values({ organizationMemberId: membership.id, roleId: memberRole.id })
- .onConflictDoNothing();
+ if (organizationIds.length > 0) {
+ const existingOrganizations = await tx
+ .select({ id: organizations.id })
+ .from(organizations)
+ .where(inArray(organizations.id, organizationIds));
+ if (existingOrganizations.length !== organizationIds.length) {
+ throw new ValidationError("One or more organizations were not found");
}
+ for (const organizationId of organizationIds) {
+ await createActiveMembershipWithDefaultRoles(tx, organizationId, sub, false);
+ }
+ } else {
+ await createPersonalOrganizationForUser(tx, sub, name, {
+ name: data.personalOrganizationName,
+ slug: data.personalOrganizationSlug,
+ });
}
});
return { sub, email, name, createdAt: new Date().toISOString(), lastActivityAt: null };
diff --git a/packages/api/src/services/audit.ts b/packages/api/src/services/audit.ts
index 569d5630..6a5f385c 100644
--- a/packages/api/src/services/audit.ts
+++ b/packages/api/src/services/audit.ts
@@ -11,6 +11,9 @@ export interface AuditEvent {
userId?: string;
adminId?: string;
clientId?: string;
+ organizationId?: string;
+ enterpriseConnectionId?: string;
+ enterpriseConnectionType?: string;
ipAddress: string;
userAgent?: string;
success: boolean;
@@ -33,6 +36,9 @@ export interface AuditFilters {
userId?: string;
adminId?: string;
clientId?: string;
+ organizationId?: string;
+ enterpriseConnectionId?: string;
+ enterpriseConnectionType?: string;
resourceType?: string;
resourceId?: string;
success?: boolean;
@@ -327,6 +333,11 @@ export async function logAuditEvent(context: Context, event: AuditEvent): Promis
userId: event.userId || null,
adminId,
clientId: event.clientId || null,
+ organizationId: isUuid(event.organizationId) ? event.organizationId : null,
+ enterpriseConnectionId: isUuid(event.enterpriseConnectionId)
+ ? event.enterpriseConnectionId
+ : null,
+ enterpriseConnectionType: event.enterpriseConnectionType || null,
ipAddress: event.ipAddress,
userAgent: event.userAgent || null,
success: event.success,
@@ -377,6 +388,18 @@ export function buildAuditLogConditions(filters: AuditFilters): SQL[] {
conditions.push(eq(auditLogs.clientId, filters.clientId));
}
+ if (filters.organizationId) {
+ conditions.push(eq(auditLogs.organizationId, filters.organizationId));
+ }
+
+ if (filters.enterpriseConnectionId) {
+ conditions.push(eq(auditLogs.enterpriseConnectionId, filters.enterpriseConnectionId));
+ }
+
+ if (filters.enterpriseConnectionType) {
+ conditions.push(eq(auditLogs.enterpriseConnectionType, filters.enterpriseConnectionType));
+ }
+
if (filters.resourceType) {
conditions.push(eq(auditLogs.resourceType, filters.resourceType));
}
diff --git a/packages/api/src/services/sessions.test.ts b/packages/api/src/services/sessions.test.ts
index dda663f9..de69912b 100644
--- a/packages/api/src/services/sessions.test.ts
+++ b/packages/api/src/services/sessions.test.ts
@@ -5,7 +5,7 @@ import path from "node:path";
import { test } from "node:test";
import { eq } from "drizzle-orm";
import { createPglite } from "../db/pglite.ts";
-import { scimUsers, sessions, users } from "../db/schema.ts";
+import { organizationMembers, organizations, scimUsers, sessions, users } from "../db/schema.ts";
import { UnauthorizedError } from "../errors.ts";
import type { Context } from "../types.ts";
import { sha256Base64Url } from "../utils/crypto.ts";
@@ -20,6 +20,7 @@ import {
getSessionIdFromCookie,
issueSessionCookies,
refreshSessionWithToken,
+ requireSession,
USER_AUTH_COOKIE_NAME,
USER_CSRF_COOKIE_NAME,
} from "./sessions.ts";
@@ -178,6 +179,57 @@ test("getActorFromRefreshToken resolves actor using hashed token storage", async
}
});
+test("requireSession enforces current forced OTP policy", async () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-sessions-test-"));
+ const { db, close } = await createPglite(directory);
+ const context = { db, logger: createLogger() } as Context;
+
+ try {
+ await db.insert(users).values({
+ sub: "otp-policy-user",
+ email: "otp-policy-user@example.com",
+ name: "OTP Policy",
+ });
+ const [organization] = await db
+ .insert(organizations)
+ .values({ slug: "otp-policy-org", name: "OTP Policy", forceOtp: true })
+ .returning();
+ assert.ok(organization);
+ await db.insert(organizationMembers).values({
+ organizationId: organization.id,
+ userSub: "otp-policy-user",
+ status: "active",
+ });
+ await db.insert(sessions).values({
+ id: "otp-policy-session",
+ cohort: "user",
+ userSub: "otp-policy-user",
+ expiresAt: new Date(Date.now() + 60_000),
+ data: {
+ sub: "otp-policy-user",
+ otpRequired: false,
+ otpVerified: false,
+ },
+ });
+ const request = {
+ url: "/crypto/wrapped-drk",
+ headers: {
+ host: "localhost",
+ cookie: `${USER_AUTH_COOKIE_NAME}=otp-policy-session`,
+ },
+ } as unknown as import("node:http").IncomingMessage;
+
+ await assert.rejects(
+ () => requireSession(context, request, false),
+ (error: unknown) =>
+ error instanceof UnauthorizedError && error.message === "OTP verification required"
+ );
+ } finally {
+ await close();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
+
test("issueSessionCookies sets host-prefixed auth and csrf cookies", () => {
let setCookie: string[] = [];
const response = {
diff --git a/packages/api/src/services/sessions.ts b/packages/api/src/services/sessions.ts
index a667f125..dbfc0148 100644
--- a/packages/api/src/services/sessions.ts
+++ b/packages/api/src/services/sessions.ts
@@ -383,7 +383,9 @@ export async function requireSession(
const url = new URL(request.url || "", `http://${request.headers.host}`);
const path = url.pathname || "";
const otpAllowed = path.startsWith("/otp/") || path === "/logout" || path === "/session";
- if (sessionData.otpRequired && !sessionData.otpVerified && !otpAllowed) {
+ const { isUserOtpRequired } = await import("../models/rbac.ts");
+ const otpRequired = await isUserOtpRequired(context, sessionData.sub);
+ if (otpRequired && !sessionData.otpVerified && !otpAllowed) {
throw new UnauthorizedError("OTP verification required");
}
}
diff --git a/packages/api/src/utils/auditWrapper.ts b/packages/api/src/utils/auditWrapper.ts
index 78c98329..302db876 100644
--- a/packages/api/src/utils/auditWrapper.ts
+++ b/packages/api/src/utils/auditWrapper.ts
@@ -16,10 +16,21 @@ import {
} from "../services/sessions.ts";
import type { Context, ControllerFunction } from "../types.ts";
+interface AuditContext {
+ organizationId?: string;
+ enterpriseConnectionId?: string;
+ enterpriseConnectionType?: string;
+}
+
interface AuditConfig {
eventType: string;
resourceType?: string;
extractResourceId?: (body: unknown, params: string[]) => string | undefined;
+ extractAuditContext?: (
+ body: unknown,
+ responseData: unknown,
+ params: string[]
+ ) => AuditContext | undefined;
skipBodyCapture?: boolean;
flushAudit?: boolean;
}
@@ -276,6 +287,10 @@ export function withAudit(config: AuditConfig | string) {
}
}
+ const auditContext = auditConfig.extractAuditContext
+ ? auditConfig.extractAuditContext(requestBody ?? null, responseData ?? null, params)
+ : undefined;
+
const auditCall = logAuditEvent(context, {
eventType: auditConfig.eventType,
method: request.method || "UNKNOWN",
@@ -283,6 +298,9 @@ export function withAudit(config: AuditConfig | string) {
cohort,
userId,
adminId,
+ organizationId: auditContext?.organizationId,
+ enterpriseConnectionId: auditContext?.enterpriseConnectionId,
+ enterpriseConnectionType: auditContext?.enterpriseConnectionType,
clientId: (() => {
if (!requestBody || typeof requestBody !== "object") return undefined;
const rbRec = requestBody as Record & {
diff --git a/packages/darkauth-client/README.md b/packages/darkauth-client/README.md
index 331a48c4..25763575 100644
--- a/packages/darkauth-client/README.md
+++ b/packages/darkauth-client/README.md
@@ -13,6 +13,7 @@ The client supports both:
- **Token Management**: First-party cookie refresh by default, with optional legacy token storage
- **Data Encryption Keys (DEK)**: Support for deriving and managing data encryption keys
- **DRK Custody**: Memory-only DRK handling by default for hosted web zero-knowledge apps
+- **Organization Switching**: App-owned and hosted organization selection flows for tenant-scoped apps
- **TypeScript Support**: Full TypeScript definitions included
## Installation
@@ -82,10 +83,12 @@ The client also supports environment variables for configuration:
### Authentication Functions
-#### `initiateLogin(): Promise`
+#### `initiateLogin(options?: InitiateLoginOptions): Promise`
Starts the OAuth2/OIDC login flow with PKCE. Redirects the user to the DarkAuth authorization server.
+Pass `organizationId` when the app already knows which organization the user wants to enter. The SDK sends it as `organization_id` on `/authorize`, and DarkAuth validates active membership before issuing a code. Omit it when the app wants DarkAuth to select the only active organization or show the hosted organization selector for multi-organization users.
+
#### `handleCallback(): Promise`
Processes the OAuth callback after successful authentication. Returns an `AuthSession` object containing:
@@ -111,10 +114,84 @@ Retrieves the current in-memory session if valid. For non-ZK sessions, returns `
If `tokenStorage: 'localStorage'` or `drkStorage: 'localStorage'` is configured for a legacy app, this function can also restore those explicitly persisted values.
-#### `refreshSession(): Promise`
+#### `refreshSession(options?: { force?: boolean }): Promise`
Refreshes the current session. In default first-party mode, the browser sends the DarkAuth refresh cookie and no JavaScript-readable refresh token is required. For non-ZK sessions, returns `drk` as an empty `Uint8Array`.
+Use `{ force: true }` after a hosted organization switch so the app receives tokens for the newly selected DarkAuth session organization even if the current in-memory ID token has not expired.
+
+### Organization Switching
+
+DarkAuth treats organization switching as choosing a new authorization context. Tokens are scoped to one selected organization at a time. Apps must not merge roles or permissions across organizations.
+
+#### `listOrganizations(): Promise`
+
+Returns the current user's organizations for app-owned switcher UI. Use `status` to decide which memberships are selectable.
+
+#### `getSessionInfo(): Promise<{ authenticated: boolean; sub?: string; email?: string | null; name?: string | null; organizationId?: string; organizationSlug?: string | null }>`
+
+Returns current first-party session and organization context for app chrome before a fresh OAuth callback is needed.
+
+#### `switchOrganization(organizationId: string, options?: SwitchOrganizationOptions): Promise`
+
+Switches the selected organization. The default `authorize` mode starts a new authorization-code flow. `hosted` mode redirects to DarkAuth's `/switch-org` page.
+
+#### App-owned switcher
+
+Use this pattern when the app owns the workspace rail, menu, or account switcher UI.
+
+```typescript
+import {
+ getCurrentUser,
+ handleCallback,
+ listOrganizations,
+ switchOrganization,
+} from '@DarkAuth/client';
+
+const organizations = await listOrganizations();
+const activeOrganizationId = getCurrentUser()?.org_id;
+
+async function selectOrganization(organizationId: string) {
+ await switchOrganization(organizationId, {
+ mode: 'authorize',
+ returnTo: window.location.href,
+ });
+}
+
+const session = await handleCallback();
+const selectedOrganizationId = getCurrentUser()?.org_id;
+```
+
+After the callback, verify that `selectedOrganizationId` matches the workspace being loaded. Treat the switch as a tenant or workspace state reset: clear tenant-local caches, selected resources, open realtime subscriptions, in-flight requests, and authorization decisions before loading data for the new `org_id`.
+
+#### Hosted switcher
+
+Use this pattern when DarkAuth should own the organization picker UI.
+
+```typescript
+import { refreshSession, switchOrganization } from '@DarkAuth/client';
+
+await switchOrganization('org_123', {
+ mode: 'hosted',
+ returnTo: window.location.href,
+});
+
+const session = await refreshSession({ force: true });
+```
+
+Hosted mode redirects to DarkAuth's `/switch-org` page. DarkAuth updates the first-party session organization and returns to the app. The app then forces a refresh so the ID and access tokens reflect the selected organization.
+
+#### Token claims
+
+When organization context is resolved, ID and access tokens can include:
+
+- `org_id`: selected organization ID.
+- `org_slug`: selected organization slug.
+- `roles`: roles for the selected organization only.
+- `permissions`: permissions for the selected organization only.
+
+Use `sub` for the user identity and `org_id` for the active tenant or workspace. A user can have different roles in different organizations, so apps must authorize each request against the token's selected `org_id` and must reject resource access for a different organization.
+
### User Information
#### `getCurrentUser(): JwtClaims | null`
@@ -208,6 +285,10 @@ interface JwtClaims {
exp?: number;
iat?: number;
iss?: string;
+ org_id?: string;
+ org_slug?: string;
+ roles?: string[];
+ permissions?: string[];
}
```
@@ -226,6 +307,33 @@ type Config = {
}
```
+### `DarkAuthOrganization`
+```typescript
+type DarkAuthOrganization = {
+ organizationId: string;
+ slug: string;
+ name: string;
+ status: string;
+ roles?: Array<{ id: string; key: string; name: string }>;
+}
+```
+
+### `InitiateLoginOptions`
+```typescript
+type InitiateLoginOptions = {
+ organizationId?: string;
+ returnTo?: string;
+}
+```
+
+### `SwitchOrganizationOptions`
+```typescript
+type SwitchOrganizationOptions = {
+ mode?: 'authorize' | 'hosted';
+ returnTo?: string;
+}
+```
+
### `ClientHooks`
```typescript
type ClientHooks = {
diff --git a/packages/darkauth-client/src/index.ts b/packages/darkauth-client/src/index.ts
index 848bef1b..553dd2a9 100644
--- a/packages/darkauth-client/src/index.ts
+++ b/packages/darkauth-client/src/index.ts
@@ -38,6 +38,80 @@ export interface JwtClaims {
exp?: number;
iat?: number;
iss?: string;
+ org_id?: string;
+ org_slug?: string;
+ roles?: string[];
+ permissions?: string[];
+}
+
+export type DarkAuthOrganization = {
+ organizationId: string;
+ slug: string;
+ name: string;
+ status: string;
+ roles?: Array<{ id: string; key: string; name: string }>;
+};
+
+export type InitiateLoginOptions = {
+ organizationId?: string;
+ returnTo?: string;
+};
+
+export type SwitchOrganizationOptions = {
+ mode?: "authorize" | "hosted";
+ returnTo?: string;
+};
+
+export type RefreshSessionOptions = {
+ force?: boolean;
+};
+
+export type DarkAuthSessionInfo = {
+ authenticated: boolean;
+ sub?: string;
+ email?: string | null;
+ name?: string | null;
+ organizationId?: string;
+ organizationSlug?: string | null;
+};
+
+export type DarkAuthErrorCode =
+ | "unauthenticated_session"
+ | "invalid_organization"
+ | "org_context_required"
+ | "request_failed";
+
+export class DarkAuthError extends Error {
+ code: DarkAuthErrorCode;
+ status?: number;
+
+ constructor(message: string, code: DarkAuthErrorCode, status?: number) {
+ super(message);
+ this.name = "DarkAuthError";
+ this.code = code;
+ this.status = status;
+ }
+}
+
+export class UnauthenticatedSessionError extends DarkAuthError {
+ constructor(message = "User session required", status = 401) {
+ super(message, "unauthenticated_session", status);
+ this.name = "UnauthenticatedSessionError";
+ }
+}
+
+export class InvalidOrganizationError extends DarkAuthError {
+ constructor(message = "Invalid organization", status = 403) {
+ super(message, "invalid_organization", status);
+ this.name = "InvalidOrganizationError";
+ }
+}
+
+export class OrgContextRequiredError extends DarkAuthError {
+ constructor(message = "Organization context required", status = 400) {
+ super(message, "org_context_required", status);
+ this.name = "OrgContextRequiredError";
+ }
}
type ViteLikeEnv = Record;
@@ -141,6 +215,54 @@ function rootEndpoint(path: string): string {
return new URL(path, cfg.issuer).toString();
}
+function isSafeReturnTo(returnTo: string): boolean {
+ if (returnTo.startsWith("/")) {
+ return !returnTo.startsWith("//") && !returnTo.includes("\\");
+ }
+ try {
+ const url = new URL(returnTo);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+function requestFailed(status: number, message = "DarkAuth request failed"): DarkAuthError {
+ return new DarkAuthError(message, "request_failed", status);
+}
+
+async function readErrorPayload(response: Response): Promise | null> {
+ try {
+ const data = (await response.json()) as unknown;
+ if (data && typeof data === "object" && !Array.isArray(data)) {
+ return data as Record;
+ }
+ } catch {}
+ return null;
+}
+
+async function errorForResponse(response: Response): Promise {
+ const payload = await readErrorPayload(response);
+ const code =
+ typeof payload?.code === "string"
+ ? payload.code
+ : typeof payload?.error === "string"
+ ? payload.error
+ : undefined;
+ const message =
+ typeof payload?.message === "string"
+ ? payload.message
+ : typeof payload?.error_description === "string"
+ ? payload.error_description
+ : undefined;
+ if (response.status === 401) return new UnauthenticatedSessionError(message, response.status);
+ if (code === "ORG_CONTEXT_REQUIRED" || code === "org_context_required") {
+ return new OrgContextRequiredError(message, response.status);
+ }
+ if (response.status === 403) return new InvalidOrganizationError(message, response.status);
+ return requestFailed(response.status, message);
+}
+
async function resolveEndpoints(): Promise {
const cacheKey = [
cfg.issuer,
@@ -339,7 +461,7 @@ export function isTokenValid(token: string): boolean {
return claims.exp * 1000 > Date.now() + 5000;
}
-export async function initiateLogin(): Promise {
+export async function initiateLogin(options: InitiateLoginOptions = {}): Promise {
const zkEnabled = cfg.zk !== false;
let zkPubParam: string | undefined;
if (zkEnabled) {
@@ -367,6 +489,7 @@ export async function initiateLogin(): Promise {
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
if (zkEnabled && zkPubParam) authUrl.searchParams.set("zk_pub", zkPubParam);
+ if (options.organizationId) authUrl.searchParams.set("organization_id", options.organizationId);
location.assign(authUrl.toString());
}
@@ -567,7 +690,13 @@ export function getStoredSession(): AuthSession | null {
};
}
-export async function refreshSession(): Promise {
+export async function refreshSession(
+ options: RefreshSessionOptions = {}
+): Promise {
+ if (!options.force) {
+ const current = getStoredSession();
+ if (current) return current;
+ }
const currentRefreshMode = refreshMode();
const refreshToken =
currentRefreshMode === "token"
@@ -590,6 +719,8 @@ export async function refreshSession(): Promise {
credentials: fetchCredentials(),
});
if (!response.ok) {
+ const error = await errorForResponse(response);
+ if (error instanceof OrgContextRequiredError) throw error;
if (response.status === 401) {
if (currentRefreshMode === "token") {
const latestRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
@@ -631,6 +762,62 @@ export async function refreshSession(): Promise {
});
}
+export async function listOrganizations(): Promise {
+ const response = await fetch(rootEndpoint("/api/user/organizations"), {
+ credentials: fetchCredentials(),
+ });
+ if (!response.ok) throw await errorForResponse(response);
+ const data = (await response.json()) as { organizations?: unknown };
+ if (!Array.isArray(data.organizations)) return [];
+ return data.organizations.filter((org): org is DarkAuthOrganization => {
+ if (!org || typeof org !== "object") return false;
+ const candidate = org as Partial;
+ return (
+ typeof candidate.organizationId === "string" &&
+ typeof candidate.slug === "string" &&
+ typeof candidate.name === "string" &&
+ typeof candidate.status === "string"
+ );
+ });
+}
+
+export async function getSessionInfo(): Promise {
+ const response = await fetch(rootEndpoint("/api/user/session"), {
+ credentials: fetchCredentials(),
+ });
+ if (response.status === 401) return { authenticated: false };
+ if (!response.ok) throw await errorForResponse(response);
+ const data = (await response.json()) as Partial;
+ return {
+ authenticated: data.authenticated === true,
+ sub: typeof data.sub === "string" ? data.sub : undefined,
+ email: typeof data.email === "string" || data.email === null ? data.email : undefined,
+ name: typeof data.name === "string" || data.name === null ? data.name : undefined,
+ organizationId: typeof data.organizationId === "string" ? data.organizationId : undefined,
+ organizationSlug:
+ typeof data.organizationSlug === "string" || data.organizationSlug === null
+ ? data.organizationSlug
+ : undefined,
+ };
+}
+
+export async function switchOrganization(
+ organizationId: string,
+ options: SwitchOrganizationOptions = {}
+): Promise {
+ if ((options.mode || "authorize") === "authorize") {
+ await initiateLogin({ organizationId, returnTo: options.returnTo });
+ return;
+ }
+ const switchUrl = new URL(rootEndpoint("/switch-org"));
+ switchUrl.searchParams.set("organization_id", organizationId);
+ switchUrl.searchParams.set("client_id", cfg.clientId);
+ if (options.returnTo && isSafeReturnTo(options.returnTo)) {
+ switchUrl.searchParams.set("return_to", options.returnTo);
+ }
+ location.assign(switchUrl.toString());
+}
+
export function logout(): void {
memorySession = null;
memoryRefreshToken = null;
diff --git a/packages/darkauth-client/tests/initiateLogin.test.js b/packages/darkauth-client/tests/initiateLogin.test.js
index 6eac1a69..a304b9fd 100644
--- a/packages/darkauth-client/tests/initiateLogin.test.js
+++ b/packages/darkauth-client/tests/initiateLogin.test.js
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { webcrypto } from "node:crypto";
-import { initiateLogin, setConfig } from "../dist/index.js";
+import { initiateLogin, setConfig, switchOrganization } from "../dist/index.js";
function createStorage() {
const entries = new Map();
@@ -77,6 +77,68 @@ test("initiateLogin adds ZK parameters when zk is true", async () => {
assert.ok(globalThis.sessionStorage.getItem("zk_eph_priv_jwk"));
});
+test("initiateLogin includes organization_id when organizationId is supplied", async () => {
+ setupEnvironment();
+ const { location, getAssignedUrl } = createLocation();
+ globalThis.location = location;
+ setConfig({
+ issuer: "https://issuer.example",
+ clientId: "client-id",
+ redirectUri: "https://app.example/callback",
+ zk: false,
+ discovery: false,
+ });
+
+ await initiateLogin({ organizationId: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff" });
+
+ const url = new URL(getAssignedUrl());
+ assert.equal(url.searchParams.get("organization_id"), "8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
+});
+
+test("switchOrganization starts authorize flow by default", async () => {
+ setupEnvironment();
+ const { location, getAssignedUrl } = createLocation();
+ globalThis.location = location;
+ setConfig({
+ issuer: "https://issuer.example",
+ clientId: "client-id",
+ redirectUri: "https://app.example/callback",
+ zk: false,
+ discovery: false,
+ });
+
+ 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");
+});
+
+test("switchOrganization can generate hosted switch URL", async () => {
+ setupEnvironment();
+ const { location, getAssignedUrl } = createLocation();
+ globalThis.location = location;
+ setConfig({
+ issuer: "https://issuer.example",
+ clientId: "client-id",
+ redirectUri: "https://app.example/callback",
+ zk: false,
+ discovery: false,
+ });
+
+ await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff", {
+ mode: "hosted",
+ returnTo: "https://app.example/workspace",
+ });
+
+ const url = new URL(getAssignedUrl());
+ assert.equal(url.pathname, "/switch-org");
+ assert.equal(url.searchParams.get("organization_id"), "8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
+ assert.equal(url.searchParams.get("client_id"), "client-id");
+ assert.equal(url.searchParams.get("return_to"), "https://app.example/workspace");
+});
+
test("initiateLogin omits ZK parameters when zk is false", async () => {
setupEnvironment();
const { location, getAssignedUrl } = createLocation();
diff --git a/packages/darkauth-client/tests/organizations.test.js b/packages/darkauth-client/tests/organizations.test.js
new file mode 100644
index 00000000..fd1f26e6
--- /dev/null
+++ b/packages/darkauth-client/tests/organizations.test.js
@@ -0,0 +1,127 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ getSessionInfo,
+ listOrganizations,
+ setConfig,
+ UnauthenticatedSessionError,
+} from "../dist/index.js";
+
+function createStorage() {
+ const entries = new Map();
+ return {
+ getItem(key) {
+ return entries.has(key) ? entries.get(key) : null;
+ },
+ setItem(key, value) {
+ entries.set(key, String(value));
+ },
+ removeItem(key) {
+ entries.delete(key);
+ },
+ clear() {
+ entries.clear();
+ },
+ };
+}
+
+function setupEnvironment() {
+ globalThis.sessionStorage = createStorage();
+ globalThis.localStorage = createStorage();
+ setConfig({
+ issuer: "https://issuer.example",
+ clientId: "client-id",
+ redirectUri: "https://app.example/callback",
+ zk: false,
+ discovery: false,
+ });
+}
+
+test("listOrganizations fetches user organizations with roles", async () => {
+ setupEnvironment();
+ globalThis.fetch = async (url, init) => {
+ assert.equal(url, "https://issuer.example/api/user/organizations");
+ assert.equal(init.credentials, "include");
+ return {
+ ok: true,
+ json: async () => ({
+ organizations: [
+ {
+ organizationId: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff",
+ slug: "acme",
+ name: "Acme",
+ status: "active",
+ roles: [{ id: "6fcf00cb-a111-4ee2-ae62-676571e73a4d", key: "admin", name: "Admin" }],
+ },
+ ],
+ }),
+ };
+ };
+
+ const organizations = await listOrganizations();
+
+ assert.deepEqual(organizations, [
+ {
+ organizationId: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff",
+ slug: "acme",
+ name: "Acme",
+ status: "active",
+ roles: [{ id: "6fcf00cb-a111-4ee2-ae62-676571e73a4d", key: "admin", name: "Admin" }],
+ },
+ ]);
+});
+
+test("listOrganizations throws typed unauthenticated error on 401", async () => {
+ setupEnvironment();
+ globalThis.fetch = async () => ({
+ ok: false,
+ status: 401,
+ json: async () => ({ message: "User session required" }),
+ });
+
+ await assert.rejects(() => listOrganizations(), UnauthenticatedSessionError);
+});
+
+test("getSessionInfo returns unauthenticated session on 401", async () => {
+ setupEnvironment();
+ globalThis.fetch = async (url, init) => {
+ assert.equal(url, "https://issuer.example/api/user/session");
+ assert.equal(init.credentials, "include");
+ return {
+ ok: false,
+ status: 401,
+ json: async () => ({ message: "User session required" }),
+ };
+ };
+
+ const session = await getSessionInfo();
+
+ assert.deepEqual(session, { authenticated: false });
+});
+
+test("getSessionInfo returns current organization context", async () => {
+ setupEnvironment();
+ globalThis.fetch = async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ authenticated: true,
+ sub: "user-1",
+ email: "user@example.com",
+ name: "User",
+ organizationId: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff",
+ organizationSlug: "acme",
+ }),
+ });
+
+ const session = await getSessionInfo();
+
+ assert.deepEqual(session, {
+ authenticated: true,
+ sub: "user-1",
+ email: "user@example.com",
+ name: "User",
+ organizationId: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff",
+ organizationSlug: "acme",
+ });
+});
diff --git a/packages/darkauth-client/tests/refreshSession.test.js b/packages/darkauth-client/tests/refreshSession.test.js
index 7250b6ec..54d846ca 100644
--- a/packages/darkauth-client/tests/refreshSession.test.js
+++ b/packages/darkauth-client/tests/refreshSession.test.js
@@ -41,6 +41,21 @@ function setupEnvironment(config = {}) {
});
}
+function toBase64Url(value) {
+ return Buffer.from(value).toString("base64url");
+}
+
+function createIdToken(sub = "user-1") {
+ const header = toBase64Url(JSON.stringify({ alg: "none", typ: "JWT" }));
+ const payload = toBase64Url(
+ JSON.stringify({
+ sub,
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ })
+ );
+ return `${header}.${payload}.sig`;
+}
+
test("refreshSession does not clear refresh token on server errors in token mode", async () => {
setupEnvironment();
globalThis.localStorage.setItem("refresh_token", "rt-1");
@@ -76,3 +91,33 @@ test("refreshSession keeps a newer refresh token on 401 in token mode", async ()
assert.equal(result, null);
assert.equal(globalThis.localStorage.getItem("refresh_token"), "rt-4");
});
+
+test("refreshSession force refreshes even when stored id token is still valid", async () => {
+ setupEnvironment({ tokenStorage: "localStorage" });
+ const existingToken = createIdToken("user-1");
+ const refreshedToken = createIdToken("user-2");
+ globalThis.localStorage.setItem("id_token", existingToken);
+ globalThis.localStorage.setItem("refresh_token", "rt-5");
+ let fetchCalls = 0;
+ globalThis.fetch = async (_url, init) => {
+ fetchCalls += 1;
+ assert.equal(init.body.get("refresh_token"), "rt-5");
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id_token: refreshedToken,
+ access_token: "at-5",
+ refresh_token: "rt-6",
+ }),
+ };
+ };
+
+ const cached = await refreshSession();
+ const refreshed = await refreshSession({ force: true });
+
+ assert.equal(fetchCalls, 1);
+ assert.equal(cached.idToken, existingToken);
+ assert.equal(refreshed.idToken, refreshedToken);
+ assert.equal(globalThis.localStorage.getItem("refresh_token"), "rt-6");
+});
diff --git a/packages/docs/src/content/docs/developers/sdk/typescript.mdx b/packages/docs/src/content/docs/developers/sdk/typescript.mdx
index 00e6b52c..3d3ec0a2 100644
--- a/packages/docs/src/content/docs/developers/sdk/typescript.mdx
+++ b/packages/docs/src/content/docs/developers/sdk/typescript.mdx
@@ -46,6 +46,78 @@ The SDK can support explicit legacy storage options, but persistent token, ARK,
The callback returns an auth session with an ID token, optional access token, and delivered key material when the client uses ZK delivery. Current v2 flows deliver a CAK and metadata. Non-ZK flows return no usable encryption key, so application code should branch on the ZK fields instead of assuming every login returns a root key.
+## Organization switching
+
+DarkAuth organization switching selects a new authorization context. The selected organization is represented in freshly issued ID and access tokens, not just in app UI state.
+
+### App-owned switcher
+
+Use this pattern when the app renders its own workspace rail, account menu, or organization picker.
+
+```typescript
+import {
+ getCurrentUser,
+ handleCallback,
+ listOrganizations,
+ switchOrganization,
+} from "@DarkAuth/client";
+
+const organizations = await listOrganizations();
+const activeOrganizationId = getCurrentUser()?.org_id;
+
+async function chooseOrganization(organizationId: string) {
+ await switchOrganization(organizationId, {
+ mode: "authorize",
+ returnTo: window.location.href,
+ });
+}
+
+await handleCallback();
+const selectedOrganizationId = getCurrentUser()?.org_id;
+```
+
+App-owned switching uses a normal authorization-code flow with PKCE and state. DarkAuth validates active membership before issuing a code. After the callback, verify the token `org_id` before loading workspace data.
+
+Treat every organization switch as a tenant or workspace state reset. Clear tenant-local caches, selected records, in-flight requests, realtime subscriptions, and cached authorization decisions before rendering data for the new organization.
+
+### Hosted switcher
+
+Use this pattern when DarkAuth should own the organization picker UI.
+
+```typescript
+import { refreshSession, switchOrganization } from "@DarkAuth/client";
+
+await switchOrganization("org_123", {
+ mode: "hosted",
+ returnTo: window.location.href,
+});
+
+await refreshSession({ force: true });
+```
+
+Hosted mode redirects to `/switch-org`, updates the first-party DarkAuth session organization, and returns to the app. The app then forces a refresh so the current ID and access tokens reflect the selected organization.
+
+### When to send `organization_id`
+
+Send `organization_id` on `/authorize` when the app already knows the intended organization. Common cases are:
+
+- The user clicked an organization in an app-owned switcher.
+- A workspace URL or subdomain maps to a known organization.
+- The app is starting login from an organization-specific invite or deep link.
+
+With the SDK, call `initiateLogin({ organizationId })` or `switchOrganization(organizationId)`. Without the SDK, include `organization_id=` in the authorization request. Omit it when DarkAuth should select the user's only active organization or show the hosted selector for multi-organization users.
+
+### Selected organization claims
+
+When organization context is resolved, tokens can include:
+
+- `org_id`: selected organization ID.
+- `org_slug`: selected organization slug.
+- `roles`: roles for the selected organization only.
+- `permissions`: permissions for the selected organization only.
+
+Applications should use `sub` as the user identity and `org_id` as the active tenant or workspace. Do not merge roles or permissions across organizations. Reject access when a token's `org_id` does not match the resource organization.
+
## Crypto helpers
The client package also exports helper functions for base64url encoding, SHA-256, HKDF, AES-GCM encryption, note-style DEK derivation, and private-key wrapping. Use these helpers when building apps that need client-side encryption aligned with DarkAuth's custody model.
diff --git a/packages/test-suite/setup/helpers/auth.ts b/packages/test-suite/setup/helpers/auth.ts
index 67cb78e7..46e5fc83 100644
--- a/packages/test-suite/setup/helpers/auth.ts
+++ b/packages/test-suite/setup/helpers/auth.ts
@@ -379,7 +379,13 @@ export async function establishUserSession(
export async function createUserViaAdmin(
servers: TestServers,
admin: { email: string; password: string },
- user: BasicUser
+ user: BasicUser,
+ options: {
+ organizationIds?: string[];
+ createPersonalOrganization?: boolean;
+ personalOrganizationName?: string;
+ personalOrganizationSlug?: string;
+ }
): Promise<{ sub: string }> {
const cacheKey = `${servers.adminUrl}|${admin.email}`;
let session = await getAdminSession(servers, admin);
@@ -392,7 +398,11 @@ export async function createUserViaAdmin(
Origin: servers.adminUrl,
'x-csrf-token': session.csrfToken,
},
- body: JSON.stringify({ email: user.email, name: user.name })
+ body: JSON.stringify({
+ email: user.email,
+ name: user.name,
+ ...options,
+ })
});
if (createRes.status === 401) {
adminSessionCache.delete(cacheKey);
@@ -405,7 +415,11 @@ export async function createUserViaAdmin(
Origin: servers.adminUrl,
'x-csrf-token': session.csrfToken,
},
- body: JSON.stringify({ email: user.email, name: user.name })
+ body: JSON.stringify({
+ email: user.email,
+ name: user.name,
+ ...options,
+ })
});
}
if (!createRes.ok) throw new Error(`create user failed: ${createRes.status}`);
diff --git a/packages/test-suite/setup/helpers/rbac.ts b/packages/test-suite/setup/helpers/rbac.ts
index 8d6a3412..d75eb8cc 100644
--- a/packages/test-suite/setup/helpers/rbac.ts
+++ b/packages/test-suite/setup/helpers/rbac.ts
@@ -50,6 +50,55 @@ export async function getOrganizationMemberIdForUser(
return member.membershipId;
}
+export async function getOrganizationMembershipsForUser(
+ servers: TestServers,
+ adminSession: AdminSession,
+ userSub: string
+): Promise> {
+ const organizationsRes = await fetch(`${servers.adminUrl}/admin/organizations?limit=100`, {
+ headers: adminHeaders(servers, adminSession),
+ });
+ if (!organizationsRes.ok) throw new Error(`failed to list organizations: ${organizationsRes.status}`);
+ const organizationsJson = (await organizationsRes.json()) as {
+ organizations: Array<{ id?: string; organizationId?: string }>;
+ };
+ const memberships: Array<{ organizationId: string; membershipId: string; status: string }> = [];
+ for (const organization of organizationsJson.organizations) {
+ const organizationId = organization.organizationId || organization.id;
+ if (!organizationId) continue;
+ const membersRes = await fetch(`${servers.adminUrl}/admin/organizations/${organizationId}/members`, {
+ headers: adminHeaders(servers, adminSession),
+ });
+ if (!membersRes.ok) throw new Error(`failed to list organization members: ${membersRes.status}`);
+ const membersJson = (await membersRes.json()) as {
+ members: Array<{ membershipId: string; userSub: string; status: string }>;
+ };
+ const member = membersJson.members.find((item) => item.userSub === userSub);
+ if (member) {
+ memberships.push({
+ organizationId,
+ membershipId: member.membershipId,
+ status: member.status,
+ });
+ }
+ }
+ return memberships;
+}
+
+export async function getOnlyOrganizationMembershipForUser(
+ servers: TestServers,
+ adminSession: AdminSession,
+ userSub: string
+): Promise<{ organizationId: string; membershipId: string; status: string }> {
+ const memberships = await getOrganizationMembershipsForUser(servers, adminSession, userSub);
+ if (memberships.length !== 1) {
+ throw new Error(`expected one organization membership for user ${userSub}, found ${memberships.length}`);
+ }
+ const membership = memberships[0];
+ if (!membership) throw new Error(`expected one organization membership for user ${userSub}`);
+ return membership;
+}
+
export async function setOrganizationMemberRoles(
servers: TestServers,
adminSession: AdminSession,
diff --git a/packages/test-suite/tests/admin/navigation/sidebar.spec.ts b/packages/test-suite/tests/admin/navigation/sidebar.spec.ts
index 41ad142c..27c2c991 100644
--- a/packages/test-suite/tests/admin/navigation/sidebar.spec.ts
+++ b/packages/test-suite/tests/admin/navigation/sidebar.spec.ts
@@ -39,7 +39,7 @@ test.describe('Admin - Sidebar Navigation', () => {
await expect(groups).toHaveCount(4);
await expect(groups.nth(0)).toContainText(/Main[\s\S]*Dashboard/);
await expect(groups.nth(1)).toContainText(/Identity[\s\S]*Users[\s\S]*Organizations[\s\S]*Roles[\s\S]*Permissions/);
- await expect(groups.nth(2)).toContainText(/OAuth[\s\S]*Clients[\s\S]*Signing Keys/);
+ await expect(groups.nth(2)).toContainText(/OAuth[\s\S]*Clients[\s\S]*Federation[\s\S]*SCIM Tokens[\s\S]*Signing Keys/);
await expect(groups.nth(3)).toContainText(/Settings[\s\S]*Admin Users[\s\S]*Audit Logs[\s\S]*Branding[\s\S]*Email Templates[\s\S]*Settings/);
await expect(page.getByRole('link', { name: 'Signing Keys' })).toHaveAttribute(
diff --git a/packages/test-suite/tests/admin/organizations/default-organization.spec.ts b/packages/test-suite/tests/admin/organizations/default-organization.spec.ts
index 475f0f13..56d6b1bd 100644
--- a/packages/test-suite/tests/admin/organizations/default-organization.spec.ts
+++ b/packages/test-suite/tests/admin/organizations/default-organization.spec.ts
@@ -5,10 +5,10 @@ import { FIXED_TEST_ADMIN } from '../../../fixtures/testData.js';
import { getAdminSession, createAdminUserViaAdmin } from '../../../setup/helpers/auth.js';
import { ensureAdminDashboard, createSecondaryAdmin } from '../../../setup/helpers/admin.js';
-test.describe('Admin - Organizations Default', () => {
+test.describe('Admin - Organizations', () => {
let servers: TestServers;
-
let adminCred = { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password };
+ let organization: { id: string; name: string; slug: string };
test.beforeAll(async () => {
servers = await createTestServers({ testName: 'admin-organizations-default' });
@@ -26,34 +26,60 @@ test.describe('Admin - Organizations Default', () => {
{ ...secondary, role: 'write' }
);
adminCred = { email: secondary.email, password: secondary.password };
+ const adminSession = await getAdminSession(servers, adminCred);
+ const slug = `playwright-org-${Date.now()}`;
+ const res = await fetch(`${servers.adminUrl}/admin/organizations`, {
+ method: 'POST',
+ headers: {
+ Cookie: adminSession.cookieHeader,
+ Origin: servers.adminUrl,
+ 'Content-Type': 'application/json',
+ 'x-csrf-token': adminSession.csrfToken,
+ },
+ body: JSON.stringify({
+ name: 'Playwright Organization',
+ slug,
+ }),
+ });
+ expect(res.ok).toBeTruthy();
+ const created = await res.json() as {
+ organization: { organizationId?: string; id?: string; name: string; slug: string };
+ };
+ const createdOrganization = created.organization;
+ organization = {
+ id: createdOrganization.organizationId || createdOrganization.id || '',
+ name: createdOrganization.name,
+ slug: createdOrganization.slug,
+ };
});
test.afterAll(async () => {
if (servers) await destroyTestServers(servers);
});
- test('Default organization exists and can be opened from UI', async ({ page }) => {
+ test('created organization can be opened from UI', async ({ page }) => {
await ensureAdminDashboard(page, servers, adminCred);
await page.click('a[href="/organizations"]');
await expect(page.getByRole('heading', { name: 'Organizations', exact: true })).toBeVisible();
- const defaultRow = page
- .locator('tbody tr', { has: page.locator('code', { hasText: 'default' }) })
+ const organizationRow = page
+ .locator('tbody tr', { has: page.locator('code', { hasText: organization.slug }) })
.first();
- await expect(defaultRow).toBeVisible();
- await defaultRow.locator('button', { hasText: 'Default' }).click();
+ await expect(organizationRow).toBeVisible();
+ await organizationRow.locator('button', { hasText: organization.name }).click();
await expect(page.getByRole('heading', { name: 'Manage Organization', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Members', exact: true })).toBeVisible();
});
- test('Default organization exists via API', async () => {
+ test('created organization exists via API', async () => {
const adminSession = await getAdminSession(servers, adminCred);
const res = await fetch(`${servers.adminUrl}/admin/organizations`, {
headers: { Cookie: adminSession.cookieHeader, Origin: servers.adminUrl }
});
expect(res.ok).toBeTruthy();
- const json = await res.json() as { organizations: Array<{ slug: string }> };
- const def = json.organizations.find(org => org.slug === 'default');
- expect(def).toBeTruthy();
+ const json = await res.json() as { organizations: Array<{ id?: string; organizationId?: string; slug: string }> };
+ const found = json.organizations.find(org => org.slug === organization.slug);
+ expect(found).toBeTruthy();
+ expect(found?.organizationId || found?.id).toBe(organization.id);
});
});
diff --git a/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts b/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts
index 10584c2e..aae5e946 100644
--- a/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts
+++ b/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts
@@ -34,8 +34,13 @@ async function fillField(root: Page | Locator, label: string, value: string) {
}
async function selectField(root: Page | Locator, page: Page, label: string, option: string) {
- await field(root, label).getByRole('combobox').click();
- await page.getByRole('option', { name: option, exact: true }).click();
+ await clickElement(field(root, label).getByRole('combobox'));
+ await clickElement(page.getByRole('option', { name: option, exact: true }));
+}
+
+async function selectFirstFieldOption(root: Page | Locator, page: Page, label: string) {
+ await clickElement(field(root, label).getByRole('combobox'));
+ await page.keyboard.press('Enter');
}
async function openRowAction(row: Locator, name: string) {
@@ -96,7 +101,9 @@ test.describe('Admin - user key management UI', () => {
password: 'Passw0rd!keyed-user',
name: 'Keyed User',
};
- const created = await createUserViaAdmin(servers, adminCred, user);
+ const created = await createUserViaAdmin(servers, adminCred, user, {
+ createPersonalOrganization: true,
+ });
keyedUserSub = created.sub;
const context = servers.getContext();
const keyId = `key-${uniqueId('admin-ui')}`;
@@ -157,6 +164,7 @@ test.describe('Admin - user key management UI', () => {
await expect(dialog.getByRole('heading', { name: 'New Federation Connection' })).toBeVisible();
await fillField(dialog, 'Name', name);
+ await selectFirstFieldOption(dialog, page, 'Organization *');
await fillField(dialog, 'Issuer', issuer);
await fillField(dialog, 'Client ID', `client-${suffix}`);
await fillField(dialog, 'Client Secret', `secret-${suffix}`);
@@ -201,6 +209,7 @@ test.describe('Admin - user key management UI', () => {
await page.getByRole('button', { name: 'New Token' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Create SCIM Token' })).toBeVisible();
+ await selectFirstFieldOption(dialog, page, 'Organization *');
await fillField(dialog, 'Name', tokenName);
await dialog.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Copy this SCIM bearer token now')).toBeVisible();
diff --git a/packages/test-suite/tests/admin/users/default-organization-membership.spec.ts b/packages/test-suite/tests/admin/users/default-organization-membership.spec.ts
index da154554..419a9c1d 100644
--- a/packages/test-suite/tests/admin/users/default-organization-membership.spec.ts
+++ b/packages/test-suite/tests/admin/users/default-organization-membership.spec.ts
@@ -3,9 +3,9 @@ import { createTestServers, destroyTestServers, type TestServers } from '../../.
import { installDarkAuth } from '../../../setup/install.js';
import { FIXED_TEST_ADMIN } from '../../../fixtures/testData.js';
import { createUserViaAdmin, getAdminSession } from '../../../setup/helpers/auth.js';
-import { getDefaultOrganizationId } from '../../../setup/helpers/rbac.js';
+import { getOnlyOrganizationMembershipForUser } from '../../../setup/helpers/rbac.js';
-test.describe('Admin - Default organization membership', () => {
+test.describe('Admin - User organization assignment', () => {
let servers: TestServers;
test.beforeAll(async () => {
@@ -23,21 +23,24 @@ test.describe('Admin - Default organization membership', () => {
if (servers) await destroyTestServers(servers);
});
- test('new users get default organization membership automatically', async () => {
- const user = { email: `auto-default-${Date.now()}@example.com`, password: 'Passw0rd!auto', name: 'Auto Default' };
- const { sub } = await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user);
+ test('new users can be created with a personal organization membership', async () => {
+ const user = {
+ email: `personal-org-${Date.now()}@example.com`,
+ password: 'Passw0rd!auto',
+ name: 'Personal Organization User',
+ };
+ const { sub } = await createUserViaAdmin(
+ servers,
+ { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
+ user,
+ { createPersonalOrganization: true }
+ );
const adminSession = await getAdminSession(servers, {
email: FIXED_TEST_ADMIN.email,
password: FIXED_TEST_ADMIN.password,
});
- const defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession);
- const res = await fetch(`${servers.adminUrl}/admin/organizations/${defaultOrganizationId}/members`, {
- headers: { Cookie: adminSession.cookieHeader, Origin: servers.adminUrl }
- });
- expect(res.ok).toBeTruthy();
- const json = await res.json() as { members: Array<{ userSub: string; status: string }> };
- const member = json.members.find((entry) => entry.userSub === sub);
- expect(member?.status).toBe('active');
+ const membership = await getOnlyOrganizationMembershipForUser(servers, adminSession, sub);
+ expect(membership.status).toBe('active');
});
});
diff --git a/packages/test-suite/tests/admin/users/users.spec.ts b/packages/test-suite/tests/admin/users/users.spec.ts
index 0448ff92..24766442 100644
--- a/packages/test-suite/tests/admin/users/users.spec.ts
+++ b/packages/test-suite/tests/admin/users/users.spec.ts
@@ -40,7 +40,8 @@ test.describe('Admin - Users', () => {
await expect(page.getByRole('heading', { name: 'Create User', exact: true })).toBeVisible();
await page.fill('input#email, input[name="email"]', email);
await page.fill('input#name, input[name="name"]', name);
- await page.getByRole('button', { name: 'Create' }).click();
+ await page.getByRole('button', { name: 'Create personal' }).click();
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.getByText('Temporary Password')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: 'Done' }).click();
await page.goto(`${servers.adminUrl}/users`);
@@ -54,7 +55,8 @@ test.describe('Admin - Users', () => {
await page.getByRole('button', { name: 'Add User' }).click();
await page.fill('input#email, input[name="email"]', email);
await page.fill('input#name, input[name="name"]', name);
- await page.getByRole('button', { name: 'Create' }).click();
+ await page.getByRole('button', { name: 'Create personal' }).click();
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.getByText('Temporary Password')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: 'Done' }).click();
await page.goto(`${servers.adminUrl}/users`);
diff --git a/packages/test-suite/tests/api/oidc-auth-edge-cases.spec.ts b/packages/test-suite/tests/api/oidc-auth-edge-cases.spec.ts
index 4622dbf9..0f865331 100644
--- a/packages/test-suite/tests/api/oidc-auth-edge-cases.spec.ts
+++ b/packages/test-suite/tests/api/oidc-auth-edge-cases.spec.ts
@@ -167,7 +167,7 @@ test.describe('API - OIDC authorize/token edge cases', () => {
await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
)
loginSession = await opaqueLoginFinish(servers.userUrl, user.email, user.password)
publicRedirectUri = await getClientRedirectUri(servers, 'demo-public-client')
diff --git a/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts b/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts
index ca5d7220..d906d916 100644
--- a/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts
+++ b/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts
@@ -100,7 +100,7 @@ test.describe('API - OIDC nonce auth code flow', () => {
password: 'Passw0rd!123'
}
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user)
+ await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user, { createPersonalOrganization: true })
const loginResult = await opaqueLoginFinish(servers.userUrl, user.email, user.password)
const adminSession = await getAdminSession(servers, {
email: FIXED_TEST_ADMIN.email,
@@ -218,7 +218,7 @@ test.describe('API - OIDC nonce auth code flow', () => {
password: 'Passw0rd!123'
}
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user)
+ await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user, { createPersonalOrganization: true })
const loginResult = await opaqueLoginFinish(servers.userUrl, user.email, user.password)
const adminSession = await getAdminSession(servers, {
email: FIXED_TEST_ADMIN.email,
@@ -304,7 +304,7 @@ test.describe('API - OIDC nonce auth code flow', () => {
password: 'Passw0rd!123'
}
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user)
+ await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user, { createPersonalOrganization: true })
const loginResult = await opaqueLoginFinish(servers.userUrl, user.email, user.password)
const adminSession = await getAdminSession(servers, {
email: FIXED_TEST_ADMIN.email,
@@ -419,7 +419,7 @@ test.describe('API - OIDC nonce auth code flow', () => {
password: 'Passw0rd!123'
}
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user)
+ await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user, { createPersonalOrganization: true })
const loginResult = await opaqueLoginFinish(servers.userUrl, user.email, user.password)
const adminSession = await getAdminSession(servers, {
email: FIXED_TEST_ADMIN.email,
diff --git a/packages/test-suite/tests/api/otp-group-policy.spec.ts b/packages/test-suite/tests/api/otp-group-policy.spec.ts
index 117a916e..d1f09f85 100644
--- a/packages/test-suite/tests/api/otp-group-policy.spec.ts
+++ b/packages/test-suite/tests/api/otp-group-policy.spec.ts
@@ -89,7 +89,8 @@ test.describe('API - OTP Role Policy', () => {
const { sub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user,
+ { organizationIds: [defaultOrganizationId] }
);
const memberId = await getOrganizationMemberIdForUser(
servers,
@@ -117,7 +118,8 @@ test.describe('API - OTP Role Policy', () => {
await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user,
+ { organizationIds: [defaultOrganizationId] }
);
const login = await opaqueLoginFinish(servers.userUrl, user.email, user.password);
expect(login.otpRequired).toBe(true);
diff --git a/packages/test-suite/tests/api/password-reset.spec.ts b/packages/test-suite/tests/api/password-reset.spec.ts
index 6935f4d9..4f2277a3 100644
--- a/packages/test-suite/tests/api/password-reset.spec.ts
+++ b/packages/test-suite/tests/api/password-reset.spec.ts
@@ -171,7 +171,7 @@ test.describe('API - Password reset', () => {
await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
);
const known = await request.post(`${servers.userUrl}/api/user/password/reset/request`, {
@@ -222,7 +222,7 @@ test.describe('API - Password reset', () => {
const { sub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
);
const adminSession = await getAdminSession(servers, {
email: FIXED_TEST_ADMIN.email,
@@ -254,7 +254,7 @@ test.describe('API - Password reset', () => {
const { sub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
);
const created = await createPasswordResetToken(servers.getContext(), {
userSub: sub,
@@ -290,7 +290,7 @@ test.describe('API - Password reset', () => {
const { sub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
);
const created = await createPasswordResetToken(servers.getContext(), {
userSub: sub,
@@ -335,7 +335,7 @@ test.describe('API - Password reset', () => {
const { sub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
);
const oldLogin = await opaqueLogin(servers, user.email, user.password, request);
expect(oldLogin.status).toBe(200);
diff --git a/packages/test-suite/tests/api/user-key-management-delivery.spec.ts b/packages/test-suite/tests/api/user-key-management-delivery.spec.ts
index 3d4fe376..0a2fef52 100644
--- a/packages/test-suite/tests/api/user-key-management-delivery.spec.ts
+++ b/packages/test-suite/tests/api/user-key-management-delivery.spec.ts
@@ -4,6 +4,7 @@ import { createTestServers, destroyTestServers, type TestServers } from '../../s
import { installDarkAuth } from '../../setup/install.js'
import { FIXED_TEST_ADMIN } from '../../fixtures/testData.js'
import { createUserViaAdmin, getAdminSession } from '../../setup/helpers/auth.js'
+import { getOnlyOrganizationMembershipForUser } from '../../setup/helpers/rbac.js'
import { OpaqueClient } from '@DarkAuth/api/src/lib/opaque/opaque-ts-wrapper.ts'
import { fromBase64Url, hkdf, sha256, sha256Base64Url, toBase64Url } from '@DarkAuth/api/src/utils/crypto.ts'
@@ -206,23 +207,6 @@ async function createOrganization(input: {
return json.organization.id
}
-async function listDefaultOrganization(input: {
- servers: TestServers
- adminSession: Session
-}): Promise {
- const res = await fetch(`${input.servers.adminUrl}/admin/organizations?limit=100`, {
- headers: {
- Cookie: input.adminSession.cookieHeader,
- Origin: input.servers.adminUrl,
- }
- })
- if (!res.ok) throw new Error(`list organizations failed: ${res.status} ${await res.text()}`)
- const json = await res.json() as { organizations: Array<{ id: string; slug: string }> }
- const organization = json.organizations.find((item) => item.slug === 'default')
- if (!organization) throw new Error('default organization not found')
- return organization.id
-}
-
async function addOrganizationMember(input: {
servers: TestServers
adminSession: Session
@@ -456,7 +440,7 @@ test.describe('API - user key management delivery matrix', () => {
name: 'User Key Delivery',
password: 'Passw0rd!123'
}
- const created = await createUserViaAdmin(servers, FIXED_TEST_ADMIN, user)
+ const created = await createUserViaAdmin(servers, FIXED_TEST_ADMIN, user, { createPersonalOrganization: true })
sub = created.sub
userSession = await opaqueLoginFinish(servers.userUrl, user.email, user.password)
keyId = await createAccountKey({
@@ -464,7 +448,9 @@ test.describe('API - user key management delivery matrix', () => {
session: userSession,
keyId: `ark_${sub}_delivery_1`,
})
- defaultOrganizationId = await listDefaultOrganization({ servers, adminSession })
+ defaultOrganizationId = (
+ await getOnlyOrganizationMembershipForUser(servers, adminSession, sub)
+ ).organizationId
secondOrganizationId = await createOrganization({
servers,
adminSession,
diff --git a/packages/test-suite/tests/api/user-key-management-provisioning.spec.ts b/packages/test-suite/tests/api/user-key-management-provisioning.spec.ts
index 88fdcebf..05fa7e35 100644
--- a/packages/test-suite/tests/api/user-key-management-provisioning.spec.ts
+++ b/packages/test-suite/tests/api/user-key-management-provisioning.spec.ts
@@ -6,6 +6,7 @@ import { createTestServers, destroyTestServers, type TestServers } from '../../s
import { installDarkAuth } from '../../setup/install.js'
import { FIXED_TEST_ADMIN } from '../../fixtures/testData.js'
import { getAdminSession } from '../../setup/helpers/auth.js'
+import { getDefaultOrganizationId } from '../../setup/helpers/rbac.js'
import { sha256Base64Url } from '@DarkAuth/api/src/utils/crypto.ts'
type Session = { cookieHeader: string; csrfToken: string }
@@ -134,6 +135,7 @@ async function createFederationConnection(input: {
servers: TestServers
adminSession: Session
provider: MockOidcProvider
+ organizationId: string
}) {
const res = await fetch(`${input.servers.adminUrl}/admin/federation/connections`, {
method: 'POST',
@@ -152,6 +154,7 @@ async function createFederationConnection(input: {
jwksUri: input.provider.jwksUri,
scopes: ['openid', 'profile', 'email'],
accountLinkingPolicy: 'email_verified',
+ organizationId: input.organizationId,
domains: ['example.com'],
enabled: true,
})
@@ -396,7 +399,7 @@ async function assertZkRequiresUnlock(input: {
expect(await finalized.text()).toContain('Key unlock is required')
}
-async function createScimToken(servers: TestServers, adminSession: Session) {
+async function createScimToken(servers: TestServers, adminSession: Session, organizationId: string) {
const res = await fetch(`${servers.adminUrl}/admin/scim/tokens`, {
method: 'POST',
headers: {
@@ -405,7 +408,7 @@ async function createScimToken(servers: TestServers, adminSession: Session) {
'x-csrf-token': adminSession.csrfToken,
'Content-Type': 'application/json',
},
- body: JSON.stringify({ name: 'Provisioning E2E' }),
+ body: JSON.stringify({ name: 'Provisioning E2E', organizationId }),
})
if (!res.ok) throw new Error(`create scim token failed: ${res.status} ${await res.text()}`)
const json = await res.json() as { token: string }
@@ -462,6 +465,7 @@ test.describe('API - user key management federation and SCIM E2E', () => {
let servers: TestServers
let provider: MockOidcProvider
let adminSession: Session
+ let defaultOrganizationId: string
let connectionId: string
let scimToken: string
@@ -479,8 +483,14 @@ test.describe('API - user key management federation and SCIM E2E', () => {
email: FIXED_TEST_ADMIN.email,
password: FIXED_TEST_ADMIN.password
})
- connectionId = await createFederationConnection({ servers, adminSession, provider })
- scimToken = await createScimToken(servers, adminSession)
+ defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession)
+ connectionId = await createFederationConnection({
+ servers,
+ adminSession,
+ provider,
+ organizationId: defaultOrganizationId,
+ })
+ scimToken = await createScimToken(servers, adminSession, defaultOrganizationId)
})
test.afterAll(async () => {
diff --git a/packages/test-suite/tests/api/user-key-management-unlock-journeys.spec.ts b/packages/test-suite/tests/api/user-key-management-unlock-journeys.spec.ts
index fb36c325..02b121e4 100644
--- a/packages/test-suite/tests/api/user-key-management-unlock-journeys.spec.ts
+++ b/packages/test-suite/tests/api/user-key-management-unlock-journeys.spec.ts
@@ -547,7 +547,7 @@ test.describe('API - user key management unlock journeys', () => {
name: 'User Key Unlock',
password: 'Passw0rd!123'
}
- const created = await createUserViaAdmin(servers, FIXED_TEST_ADMIN, user)
+ const created = await createUserViaAdmin(servers, FIXED_TEST_ADMIN, user, { createPersonalOrganization: true })
sub = created.sub
passwordSession = await opaqueLoginFinish(servers.userUrl, user.email, user.password)
keyId = await createAccountKey(servers, passwordSession, `ark_${sub}_unlock_1`)
diff --git a/packages/test-suite/tests/api/users-endpoint-auth.spec.ts b/packages/test-suite/tests/api/users-endpoint-auth.spec.ts
index bca756dc..87834ea7 100644
--- a/packages/test-suite/tests/api/users-endpoint-auth.spec.ts
+++ b/packages/test-suite/tests/api/users-endpoint-auth.spec.ts
@@ -114,6 +114,26 @@ async function getClientCredentialsAccessToken(
return tokenJson.access_token as string
}
+async function createOrganization(
+ servers: TestServers,
+ adminSession: { cookieHeader: string; csrfToken: string },
+ input: { slug: string; name: string }
+): Promise {
+ const res = await fetch(`${servers.adminUrl}/admin/organizations`, {
+ method: 'POST',
+ headers: {
+ Cookie: adminSession.cookieHeader,
+ Origin: servers.adminUrl,
+ 'x-csrf-token': adminSession.csrfToken,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(input)
+ })
+ if (!res.ok) throw new Error(`create organization failed: ${res.status} ${await res.text()}`)
+ const json = await res.json() as { organization: { id: string } }
+ return json.organization.id
+}
+
test.describe('API - Users endpoint auth methods', () => {
test.describe.configure({ mode: 'serial' })
@@ -198,21 +218,25 @@ test.describe('API - Users endpoint auth methods', () => {
name: 'Directory Plain',
password: 'Passw0rd!123'
}
+ const organizationId = await createOrganization(servers, adminSession, {
+ slug: `directory-${Date.now()}`,
+ name: 'Directory Test'
+ })
const { sub: readerSub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- reader
+ reader, { organizationIds: [organizationId] }
)
const { sub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- target
+ target, { organizationIds: [organizationId] }
)
await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- plain
+ plain, { organizationIds: [organizationId] }
)
targetSub = sub
targetName = target.name
diff --git a/packages/test-suite/tests/auth/login-otp-policy.spec.ts b/packages/test-suite/tests/auth/login-otp-policy.spec.ts
index 8603316f..680b2811 100644
--- a/packages/test-suite/tests/auth/login-otp-policy.spec.ts
+++ b/packages/test-suite/tests/auth/login-otp-policy.spec.ts
@@ -68,7 +68,8 @@ test.describe('Auth - Login OTP Policy', () => {
await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user,
+ { organizationIds: [defaultOrganizationId] }
);
const finish = await opaqueLoginFinish(servers.userUrl, user.email, user.password);
expect(finish.otpRequired).toBe(true);
@@ -77,7 +78,12 @@ test.describe('Auth - Login OTP Policy', () => {
test('User in organization without force OTP gets otpRequired=false', async () => {
await setOrganizationForceOtp(servers, adminSession, defaultOrganizationId, false);
const user = { email: `b-${Date.now()}@example.com`, name: 'User B', password: 'Passw0rd!123' };
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user);
+ await createUserViaAdmin(
+ servers,
+ { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
+ user,
+ { organizationIds: [defaultOrganizationId] }
+ );
const finish = await opaqueLoginFinish(servers.userUrl, user.email, user.password);
expect(finish.otpRequired).toBe(false);
});
@@ -85,7 +91,12 @@ test.describe('Auth - Login OTP Policy', () => {
test('Force OTP applies to subsequent logins when enabled', async () => {
await setOrganizationForceOtp(servers, adminSession, defaultOrganizationId, true);
const user = { email: `c-${Date.now()}@example.com`, name: 'User C', password: 'Passw0rd!123' };
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user);
+ await createUserViaAdmin(
+ servers,
+ { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
+ user,
+ { organizationIds: [defaultOrganizationId] }
+ );
const finish = await opaqueLoginFinish(servers.userUrl, user.email, user.password);
expect(finish.otpRequired).toBe(true);
});
diff --git a/packages/test-suite/tests/auth/user-otp-backup-codes.spec.ts b/packages/test-suite/tests/auth/user-otp-backup-codes.spec.ts
index aa532ad9..7a850a25 100644
--- a/packages/test-suite/tests/auth/user-otp-backup-codes.spec.ts
+++ b/packages/test-suite/tests/auth/user-otp-backup-codes.spec.ts
@@ -29,10 +29,16 @@ test.describe('Auth - User OTP backup codes (UI)', () => {
test('Setup via UI shows backup codes; a code works on /otp/verify', async ({ page }) => {
const user = { email: `bc-${Date.now()}@example.com`, name: 'Backup Codes', password: 'Passw0rd!123' };
+ const adminSession = await getAdminSession(servers, {
+ email: FIXED_TEST_ADMIN.email,
+ password: FIXED_TEST_ADMIN.password,
+ });
+ const defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession);
await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user,
+ { organizationIds: [defaultOrganizationId] }
);
await page.goto(`${servers.userUrl}/`);
await page.fill('input[name="email"], input[type="email"]', user.email);
@@ -53,11 +59,6 @@ test.describe('Auth - User OTP backup codes (UI)', () => {
const backupCode = await page.locator('ul li').first().textContent();
expect(backupCode && backupCode.includes('-')).toBeTruthy();
- const adminSession = await getAdminSession(servers, {
- email: FIXED_TEST_ADMIN.email,
- password: FIXED_TEST_ADMIN.password,
- });
- const defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession);
await setOrganizationForceOtp(servers, adminSession, defaultOrganizationId, true);
await page.context().clearCookies();
diff --git a/packages/test-suite/tests/auth/user-otp-setup-and-verify.spec.ts b/packages/test-suite/tests/auth/user-otp-setup-and-verify.spec.ts
index 2cabeb18..1ec8ba2b 100644
--- a/packages/test-suite/tests/auth/user-otp-setup-and-verify.spec.ts
+++ b/packages/test-suite/tests/auth/user-otp-setup-and-verify.spec.ts
@@ -25,7 +25,7 @@ test.describe('Auth - User OTP setup and verify (UI)', () => {
test('Setup via UI shows secret; verify produces backup codes', async ({ page }) => {
const user = { email: `otp-${Date.now()}@example.com`, name: 'OTP User', password: 'Passw0rd!123' };
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user);
+ await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user, { createPersonalOrganization: true });
await page.goto(`${servers.userUrl}/`);
await page.fill('input[name="email"], input[type="email"]', user.email);
await page.fill('input[name="password"], input[type="password"]', user.password);
diff --git a/packages/test-suite/tests/auth/user-otp-verify-gating.spec.ts b/packages/test-suite/tests/auth/user-otp-verify-gating.spec.ts
index 3975c582..c9bbf2fd 100644
--- a/packages/test-suite/tests/auth/user-otp-verify-gating.spec.ts
+++ b/packages/test-suite/tests/auth/user-otp-verify-gating.spec.ts
@@ -34,8 +34,13 @@ test.describe('Auth - OTP verification gating (UI)', () => {
test('Redirects to OTP setup, then verify completes flow', async ({ page }) => {
const user = { email: `og-${Date.now()}@example.com`, name: 'OTP Gate', password: 'Passw0rd!123' };
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user);
const defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession);
+ await createUserViaAdmin(
+ servers,
+ { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
+ user,
+ { organizationIds: [defaultOrganizationId] }
+ );
await setOrganizationForceOtp(servers, adminSession, defaultOrganizationId, true);
await page.goto(`${servers.userUrl}/`);
await page.fill('input[name="email"], input[type="email"]', user.email);
diff --git a/packages/test-suite/tests/security/email-verification.spec.ts b/packages/test-suite/tests/security/email-verification.spec.ts
index 8882259f..b22d1e33 100644
--- a/packages/test-suite/tests/security/email-verification.spec.ts
+++ b/packages/test-suite/tests/security/email-verification.spec.ts
@@ -112,7 +112,7 @@ test.describe('Security - Email verification', () => {
await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
);
await setAdminSetting(servers, 'users.require_email_verification', true, request);
@@ -139,7 +139,7 @@ test.describe('Security - Email verification', () => {
await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
);
await setAdminSetting(servers, 'users.require_email_verification', true, request);
diff --git a/packages/test-suite/tests/security/login-gating.spec.ts b/packages/test-suite/tests/security/login-gating.spec.ts
index c2a1ceda..5c6b0e8b 100644
--- a/packages/test-suite/tests/security/login-gating.spec.ts
+++ b/packages/test-suite/tests/security/login-gating.spec.ts
@@ -69,19 +69,20 @@ test.describe('Security - Login gating by active organization membership', () =>
test('user can/cannot login depending on active default organization membership', async () => {
const user = { email: `gating-${Date.now()}@example.com`, password: 'Passw0rd!gating', name: 'Gate User' };
+ const adminSession = await getAdminSession(servers, {
+ email: FIXED_TEST_ADMIN.email,
+ password: FIXED_TEST_ADMIN.password,
+ });
+ const defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession);
const { sub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user,
+ { organizationIds: [defaultOrganizationId] }
);
await loginFinishExpect(servers, user.email, user.password, true);
- const adminSession = await getAdminSession(servers, {
- email: FIXED_TEST_ADMIN.email,
- password: FIXED_TEST_ADMIN.password,
- });
- const defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession);
const memberId = await getOrganizationMemberIdForUser(
servers,
adminSession,
diff --git a/packages/test-suite/tests/user/auth/login.spec.ts b/packages/test-suite/tests/user/auth/login.spec.ts
index 9f10ca9c..ac5b6dea 100644
--- a/packages/test-suite/tests/user/auth/login.spec.ts
+++ b/packages/test-suite/tests/user/auth/login.spec.ts
@@ -24,7 +24,7 @@ test.describe('Authentication - User Login', () => {
test.beforeEach(async ({ page }) => {
user = createTestUser();
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user);
+ await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user, { createPersonalOrganization: true });
await page.goto(`${servers.userUrl}/login`);
});
diff --git a/packages/test-suite/tests/user/auth/password-reset-ui.spec.ts b/packages/test-suite/tests/user/auth/password-reset-ui.spec.ts
index acf9ebbe..5a60f36b 100644
--- a/packages/test-suite/tests/user/auth/password-reset-ui.spec.ts
+++ b/packages/test-suite/tests/user/auth/password-reset-ui.spec.ts
@@ -66,7 +66,7 @@ test.describe('User - Password reset UI', () => {
const { sub } = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
);
const created = await createPasswordResetToken(servers.getContext(), {
userSub: sub,
diff --git a/packages/test-suite/tests/user/dashboard/apps-visible.spec.ts b/packages/test-suite/tests/user/dashboard/apps-visible.spec.ts
index ab6c6da3..002f4180 100644
--- a/packages/test-suite/tests/user/dashboard/apps-visible.spec.ts
+++ b/packages/test-suite/tests/user/dashboard/apps-visible.spec.ts
@@ -44,7 +44,7 @@ test.describe('User Dashboard - Apps Visibility', () => {
})
test('enabled client appears on dashboard after login', async ({ page }) => {
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user)
+ await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user, { createPersonalOrganization: true })
await page.goto(`${servers.userUrl}/`)
await page.fill('input[name="email"], input[type="email"]', user.email)
diff --git a/packages/test-suite/tests/user/dashboard/dashboard.spec.ts b/packages/test-suite/tests/user/dashboard/dashboard.spec.ts
index 72a4ccc3..72f10902 100644
--- a/packages/test-suite/tests/user/dashboard/dashboard.spec.ts
+++ b/packages/test-suite/tests/user/dashboard/dashboard.spec.ts
@@ -21,7 +21,7 @@ test.describe('User Dashboard', () => {
test.beforeEach(async ({ page }) => {
user = createTestUser()
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user)
+ await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user, { createPersonalOrganization: true })
await page.goto(`${servers.userUrl}/`)
})
diff --git a/packages/test-suite/tests/user/org-switching.spec.ts b/packages/test-suite/tests/user/org-switching.spec.ts
new file mode 100644
index 00000000..b9af77d4
--- /dev/null
+++ b/packages/test-suite/tests/user/org-switching.spec.ts
@@ -0,0 +1,164 @@
+import { expect, test } from '@playwright/test'
+import { eq } from 'drizzle-orm'
+import { users } from '@DarkAuth/api/src/db/schema.ts'
+import { FIXED_TEST_ADMIN, createTestUser } from '../../fixtures/testData.js'
+import {
+ createUserViaAdmin,
+ establishUserSession,
+ getAdminSession,
+} from '../../setup/helpers/auth.js'
+import {
+ addOrganizationMember,
+ getOnlyOrganizationMembershipForUser,
+} from '../../setup/helpers/rbac.js'
+import { installDarkAuth } from '../../setup/install.js'
+import { createTestServers, destroyTestServers, type TestServers } from '../../setup/server.js'
+
+type AdminSession = { cookieHeader: string; csrfToken: string }
+
+function adminWriteHeaders(
+ servers: TestServers,
+ adminSession: AdminSession
+): Record {
+ return {
+ Cookie: adminSession.cookieHeader,
+ Origin: servers.adminUrl,
+ 'Content-Type': 'application/json',
+ 'x-csrf-token': adminSession.csrfToken,
+ }
+}
+
+async function createOrganization(
+ servers: TestServers,
+ adminSession: AdminSession,
+ input: { name: string; slug: string }
+): Promise {
+ const response = await fetch(`${servers.adminUrl}/admin/organizations`, {
+ method: 'POST',
+ headers: adminWriteHeaders(servers, adminSession),
+ body: JSON.stringify(input),
+ })
+ if (!response.ok) throw new Error(`create organization failed: ${response.status}`)
+ const json = (await response.json()) as { organization: { id: string } }
+ return json.organization.id
+}
+
+async function createPublicClient(
+ servers: TestServers,
+ adminSession: AdminSession,
+ input: { clientId: string; redirectUri: string }
+): Promise {
+ const response = await fetch(`${servers.adminUrl}/admin/clients`, {
+ method: 'POST',
+ headers: adminWriteHeaders(servers, adminSession),
+ body: JSON.stringify({
+ clientId: input.clientId,
+ name: input.clientId,
+ type: 'public',
+ tokenEndpointAuthMethod: 'none',
+ requirePkce: true,
+ zkDelivery: 'none',
+ zkRequired: false,
+ redirectUris: [input.redirectUri],
+ grantTypes: ['authorization_code', 'refresh_token'],
+ responseTypes: ['code'],
+ scopes: ['openid', 'profile'],
+ }),
+ })
+ if (!response.ok) throw new Error(`create client failed: ${response.status}`)
+}
+
+test.describe.serial('Organization switching browser flows', () => {
+ let servers: TestServers
+ let defaultOrganizationId: string
+ let secondOrganizationId: string
+ let clientId: string
+ let redirectUri: string
+ const user = { ...createTestUser(), name: 'Org Switching User' }
+
+ test.beforeAll(async () => {
+ servers = await createTestServers({ testName: 'user-org-switching' })
+ await installDarkAuth({
+ adminUrl: servers.adminUrl,
+ adminEmail: FIXED_TEST_ADMIN.email,
+ adminName: FIXED_TEST_ADMIN.name,
+ adminPassword: FIXED_TEST_ADMIN.password,
+ installToken: 'test-install-token',
+ })
+ const adminSession = await getAdminSession(servers, FIXED_TEST_ADMIN)
+ const created = await createUserViaAdmin(servers, FIXED_TEST_ADMIN, user, { createPersonalOrganization: true })
+ await servers.getContext().db
+ .update(users)
+ .set({ passwordResetRequired: false })
+ .where(eq(users.sub, created.sub))
+ defaultOrganizationId = (
+ await getOnlyOrganizationMembershipForUser(servers, adminSession, created.sub)
+ ).organizationId
+ const suffix = Date.now().toString(36)
+ secondOrganizationId = await createOrganization(servers, adminSession, {
+ name: 'Browser Switch Org',
+ slug: `browser-switch-${suffix}`,
+ })
+ await addOrganizationMember(servers, adminSession, secondOrganizationId, created.sub)
+ clientId = `browser-org-switch-${suffix}`
+ redirectUri = `${servers.userUrl}/callback/${clientId}`
+ await createPublicClient(servers, adminSession, { clientId, redirectUri })
+ })
+
+ test.afterAll(async () => {
+ if (servers) await destroyTestServers(servers)
+ })
+
+ test.beforeEach(async ({ context }) => {
+ await establishUserSession(context, servers, user)
+ })
+
+ test('multi-org authorize shows organization selector', async ({ page }) => {
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: 'openid profile',
+ state: 'selector-state',
+ code_challenge: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ code_challenge_method: 'S256',
+ })
+
+ await page.goto(`${servers.userUrl}/api/user/authorize?${params.toString()}`)
+
+ await expect(
+ page.getByRole('heading', { name: /Authorize Application|Continue to/ })
+ ).toBeVisible({ timeout: 15000 })
+ await expect(
+ page.getByText('Choose which organization to use for this sign-in.')
+ ).toBeVisible()
+ await expect(page.locator('input[name="organization_id"]')).toHaveCount(2)
+ await expect(page.getByLabel('Browser Switch Org')).toBeVisible()
+ })
+
+ test('/switch-org returns to a registered app URL', async ({ page }) => {
+ const returnTo = `${servers.userUrl}/apps?switched=1`
+ const params = new URLSearchParams({
+ client_id: clientId,
+ return_to: returnTo,
+ organization_id: secondOrganizationId,
+ })
+
+ await page.goto(`${servers.userUrl}/switch-org?${params.toString()}`)
+
+ await expect(page.getByRole('heading', { name: 'Switch organization' })).toBeVisible({
+ timeout: 15000,
+ })
+ await expect(page.getByLabel('Browser Switch Org')).toBeChecked()
+ await page.getByRole('button', { name: 'Switch organization' }).click()
+ await page.waitForURL(returnTo, { timeout: 15000 })
+
+ const sessionResponse = await page.request.get(`${servers.userUrl}/api/user/session`, {
+ headers: { Origin: servers.userUrl },
+ })
+ expect(sessionResponse.ok()).toBeTruthy()
+ const session = (await sessionResponse.json()) as { organizationId?: string }
+ expect(session.organizationId).toBe(secondOrganizationId)
+ expect(defaultOrganizationId).not.toBe(secondOrganizationId)
+ })
+})
diff --git a/packages/test-suite/tests/user/otp/forced-setup.spec.ts b/packages/test-suite/tests/user/otp/forced-setup.spec.ts
index ff590aab..53e324fb 100644
--- a/packages/test-suite/tests/user/otp/forced-setup.spec.ts
+++ b/packages/test-suite/tests/user/otp/forced-setup.spec.ts
@@ -33,8 +33,13 @@ test.describe('User - OTP - Forced setup UI', () => {
test('When required and not configured, login redirects to /otp/setup?forced=1', async ({ page }) => {
const user = { email: `otp-ui-${Date.now()}@example.com`, name: 'OTP UI', password: 'Passw0rd!123' };
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user);
const defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession);
+ await createUserViaAdmin(
+ servers,
+ { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
+ user,
+ { organizationIds: [defaultOrganizationId] }
+ );
await setOrganizationForceOtp(servers, adminSession, defaultOrganizationId, true);
await page.goto(`${servers.userUrl}/`);
diff --git a/packages/test-suite/tests/user/otp/forced-verify.spec.ts b/packages/test-suite/tests/user/otp/forced-verify.spec.ts
index 8fe31bf1..b2f7c4e3 100644
--- a/packages/test-suite/tests/user/otp/forced-verify.spec.ts
+++ b/packages/test-suite/tests/user/otp/forced-verify.spec.ts
@@ -71,8 +71,13 @@ test.describe('User - OTP - Forced verify UI', () => {
test('When required and setup is pending, login redirects to /otp/setup?forced=1', async ({ page }) => {
const user = { email: `otp-ui-${Date.now()}@example.com`, name: 'OTP UI', password: 'Passw0rd!123' };
- await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user);
const defaultOrganizationId = await getDefaultOrganizationId(servers, adminSession);
+ await createUserViaAdmin(
+ servers,
+ { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
+ user,
+ { organizationIds: [defaultOrganizationId] }
+ );
await setOrganizationForceOtp(servers, adminSession, defaultOrganizationId, true);
const session = await opaqueLogin(servers.userUrl, user.email, user.password);
diff --git a/packages/test-suite/tests/user/portal-responsive.spec.ts b/packages/test-suite/tests/user/portal-responsive.spec.ts
index 28030eed..4108e4e0 100644
--- a/packages/test-suite/tests/user/portal-responsive.spec.ts
+++ b/packages/test-suite/tests/user/portal-responsive.spec.ts
@@ -36,7 +36,7 @@ test.describe('User portal responsive UX', () => {
const created = await createUserViaAdmin(
servers,
{ email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password },
- user
+ user, { createPersonalOrganization: true }
)
await servers.getContext().db
.update(users)
diff --git a/packages/user-ui/src/App.tsx b/packages/user-ui/src/App.tsx
index d220c79d..600863ee 100644
--- a/packages/user-ui/src/App.tsx
+++ b/packages/user-ui/src/App.tsx
@@ -13,6 +13,7 @@ import Dashboard from "./components/Dashboard";
import EmailResetPasswordView from "./components/EmailResetPasswordView";
import ForgotPasswordView from "./components/ForgotPasswordView";
import LoginView from "./components/LoginView";
+import OrganizationDetail from "./components/OrganizationDetail";
import OtpSetupView from "./components/OtpSetupView";
import OtpVerifyView from "./components/OtpVerifyView";
import Profile from "./components/Profile";
@@ -554,6 +555,31 @@ function AppContent() {
)
}
/>
+
+
+
+ ) : !sessionData ? (
+
+ ) : (
+
+
+
+ )
+ }
+ />
(null);
useEffect(() => {
+ let cancelled = false;
(async () => {
+ let session: Awaited> | null = null;
+ let status: Awaited> | null = null;
try {
- const session = await apiService.getSession();
- const otpReq = !!session.otpRequired;
- const otpVer = !!session.otpVerified;
- if (otpReq && !otpVer) {
- try {
- const s = await apiService.getOtpStatus();
- setRedirect(s.enabled ? "/otp/verify" : "/otp/setup?forced=1");
- } catch {
- setRedirect("/otp/verify");
- }
- }
- } catch {
- } finally {
+ session = await apiService.getSession();
+ } catch {}
+ try {
+ status = await apiService.getOtpStatus();
+ } catch {}
+ const otpRequired = !!session?.otpRequired || !!status?.required;
+ const otpVerified = !!session?.otpVerified;
+ if (!cancelled && otpRequired && !otpVerified) {
+ setRedirect(status && !status.enabled ? "/otp/setup?forced=1" : "/otp/verify");
+ }
+ if (!cancelled) {
setReady(true);
}
})();
+ return () => {
+ cancelled = true;
+ };
}, []);
if (!ready)
return (
diff --git a/packages/user-ui/src/components/ChangePasswordView.tsx b/packages/user-ui/src/components/ChangePasswordView.tsx
index 2dbceaa0..8b15d888 100644
--- a/packages/user-ui/src/components/ChangePasswordView.tsx
+++ b/packages/user-ui/src/components/ChangePasswordView.tsx
@@ -9,6 +9,7 @@ interface ChangePasswordViewProps {
email?: string | null;
signInEmail?: string | null;
name?: string | null;
+ organizationSlug?: string | null;
};
onLogout: () => void;
}
@@ -24,6 +25,7 @@ export default function ChangePasswordView({ sessionData, onLogout }: ChangePass
navigate("/security/password")}
onLogout={onLogout}
>
diff --git a/packages/user-ui/src/components/Dashboard.test.js b/packages/user-ui/src/components/Dashboard.test.js
index 7ea66857..cc6f7c45 100644
--- a/packages/user-ui/src/components/Dashboard.test.js
+++ b/packages/user-ui/src/components/Dashboard.test.js
@@ -16,6 +16,11 @@ test("apps landing keeps app launch primary and moves account tasks to navigatio
assert.notEqual(dashboardSource.indexOf("No apps available"), -1);
assert.notEqual(layoutSource.indexOf("/security"), -1);
assert.notEqual(layoutSource.indexOf("/profile"), -1);
+ assert.notEqual(layoutSource.indexOf("Active org"), -1);
+ assert.notEqual(
+ dashboardSource.indexOf("organizationLabel={sessionData.organizationSlug || null}"),
+ -1
+ );
assert.notEqual(dashboardSource.indexOf("KeyUnlockPanel"), -1);
assert.notEqual(unlockSource.indexOf("Unlock with Password"), -1);
assert.equal(dashboardSource.indexOf("Profile and security controls"), -1);
diff --git a/packages/user-ui/src/components/Dashboard.tsx b/packages/user-ui/src/components/Dashboard.tsx
index b05afb19..cb3c9414 100644
--- a/packages/user-ui/src/components/Dashboard.tsx
+++ b/packages/user-ui/src/components/Dashboard.tsx
@@ -14,6 +14,7 @@ interface SessionData {
name?: string;
email?: string;
keyState?: KeyState;
+ organizationSlug?: string;
}
interface App {
@@ -94,7 +95,11 @@ export default function Dashboard({ sessionData }: DashboardProps) {
const showSearch = apps.length >= 7;
return (
-
+
{
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ } catch {
+ setCopied(false);
+ }
+ };
+ return (
+
+
+ {label}
+ {value}
+
+
+ {copied ? "Copied" : "Copy"}
+
+
+ );
+}
+
+function domainStatusTone(status: string): "ready" | "action" | "neutral" {
+ if (status === "verified") return "ready";
+ if (status === "failed") return "action";
+ return "neutral";
+}
+
+function isForbidden(err: unknown) {
+ const code = (err as { code?: string } | undefined)?.code;
+ return code === "FORBIDDEN" || (err instanceof Error && /403/.test(err.message));
+}
+
+const emptyConnectionInput: OrgFederationConnectionInput = {
+ name: "",
+ discoveryUrl: "",
+ clientId: "",
+ clientSecret: "",
+ emailClaim: "email",
+ nameClaim: "name",
+ subjectClaim: "sub",
+ jitProvisioning: true,
+ membershipOnAuthentication: true,
+ requireScimPreProvisioning: false,
+};
+
+export default function EnterpriseConnections({ organizationId }: EnterpriseConnectionsProps) {
+ const [managed, setManaged] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [message, setMessage] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const [federationConnections, setFederationConnections] = useState([]);
+ const [selectedFederationId, setSelectedFederationId] = useState(null);
+ const [federationForm, setFederationForm] =
+ useState(emptyConnectionInput);
+ const [creatingFederation, setCreatingFederation] = useState(false);
+ const [domains, setDomains] = useState([]);
+ const [newDomain, setNewDomain] = useState("");
+
+ const [scimConnections, setScimConnections] = useState([]);
+ const [scimName, setScimName] = useState("");
+ const [scimTokensByConnection, setScimTokensByConnection] = useState<
+ Record
+ >({});
+ const [revealedToken, setRevealedToken] = useState<{
+ connectionId: string;
+ value: string;
+ } | null>(null);
+
+ const loadAll = useCallback(async () => {
+ if (!organizationId) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const [federation, scim] = await Promise.all([
+ apiService.getOrgFederationConnections(organizationId),
+ apiService.getOrgScimConnections(organizationId),
+ ]);
+ setFederationConnections(federation);
+ setScimConnections(scim);
+ setManaged(true);
+ } catch (err) {
+ if (isForbidden(err)) {
+ setManaged(false);
+ } else {
+ setError(err instanceof Error ? err.message : "Unable to load enterprise connections.");
+ setManaged(true);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [organizationId]);
+
+ useEffect(() => {
+ loadAll();
+ }, [loadAll]);
+
+ const loadDomains = useCallback(
+ async (connectionId: string) => {
+ try {
+ const list = await apiService.getOrgFederationDomains(organizationId, connectionId);
+ setDomains(list);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to load domains.");
+ }
+ },
+ [organizationId]
+ );
+
+ const selectFederation = async (connection: OrgFederationConnection) => {
+ setCreatingFederation(false);
+ setSelectedFederationId(connection.id);
+ setMessage(null);
+ setError(null);
+ try {
+ const full = await apiService.getOrgFederationConnection(organizationId, connection.id);
+ setFederationForm({
+ name: full.name || "",
+ enabled: full.enabled,
+ discoveryUrl: full.discoveryUrl || "",
+ clientId: full.clientId || "",
+ clientSecret: "",
+ scopes: full.scopes,
+ emailClaim: full.emailClaim || "email",
+ nameClaim: full.nameClaim || "name",
+ subjectClaim: full.subjectClaim || "sub",
+ jitProvisioning: full.jitProvisioning ?? true,
+ membershipOnAuthentication: full.membershipOnAuthentication ?? true,
+ requireScimPreProvisioning: full.requireScimPreProvisioning ?? false,
+ });
+ await loadDomains(connection.id);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to load connection.");
+ }
+ };
+
+ const startCreateFederation = () => {
+ setCreatingFederation(true);
+ setSelectedFederationId(null);
+ setFederationForm(emptyConnectionInput);
+ setDomains([]);
+ setMessage(null);
+ setError(null);
+ };
+
+ const submitFederation = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!federationForm.name.trim()) return;
+ setSubmitting(true);
+ setError(null);
+ setMessage(null);
+ try {
+ const { clientSecret, ...rest } = federationForm;
+ const payload: OrgFederationConnectionInput = {
+ ...rest,
+ name: federationForm.name.trim(),
+ ...(clientSecret ? { clientSecret } : {}),
+ };
+ if (creatingFederation) {
+ const created = await apiService.createOrgFederationConnection(organizationId, payload);
+ setMessage("SSO connection created.");
+ const list = await apiService.getOrgFederationConnections(organizationId);
+ setFederationConnections(list);
+ await selectFederation(created);
+ } else if (selectedFederationId) {
+ await apiService.updateOrgFederationConnection(
+ organizationId,
+ selectedFederationId,
+ payload
+ );
+ setMessage("SSO connection updated.");
+ const list = await apiService.getOrgFederationConnections(organizationId);
+ setFederationConnections(list);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to save SSO connection.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const deleteFederation = async (connection: OrgFederationConnection) => {
+ if (!window.confirm(`Delete SSO connection ${connection.name}?`)) return;
+ setSubmitting(true);
+ setError(null);
+ try {
+ await apiService.deleteOrgFederationConnection(organizationId, connection.id);
+ setSelectedFederationId(null);
+ setCreatingFederation(false);
+ setDomains([]);
+ const list = await apiService.getOrgFederationConnections(organizationId);
+ setFederationConnections(list);
+ setMessage("SSO connection deleted.");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to delete SSO connection.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const addDomain = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!selectedFederationId || !newDomain.trim()) return;
+ setSubmitting(true);
+ setError(null);
+ try {
+ await apiService.createOrgFederationDomain(
+ organizationId,
+ selectedFederationId,
+ newDomain.trim()
+ );
+ setNewDomain("");
+ await loadDomains(selectedFederationId);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to add domain.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const removeDomain = async (domain: OrgFederationDomain) => {
+ if (!selectedFederationId) return;
+ if (!window.confirm(`Remove domain ${domain.domain}?`)) return;
+ setSubmitting(true);
+ setError(null);
+ try {
+ await apiService.deleteOrgFederationDomain(organizationId, selectedFederationId, domain.id);
+ await loadDomains(selectedFederationId);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to remove domain.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const verifyDomain = async (domain: OrgFederationDomain) => {
+ if (!selectedFederationId) return;
+ setSubmitting(true);
+ setError(null);
+ try {
+ const updated = await apiService.verifyOrgFederationDomain(
+ organizationId,
+ selectedFederationId,
+ domain.id
+ );
+ setDomains((current) =>
+ current.map((item) => (item.id === domain.id ? { ...item, ...updated } : item))
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to check verification.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const recordName = (domain: OrgFederationDomain) =>
+ domain.recordName || `_darkauth-verification.${domain.domain}`;
+ const recordValue = (domain: OrgFederationDomain) => domain.recordValue || "";
+
+ const createScim = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!scimName.trim()) return;
+ setSubmitting(true);
+ setError(null);
+ setMessage(null);
+ try {
+ await apiService.createOrgScimConnection(organizationId, scimName.trim());
+ setScimName("");
+ const list = await apiService.getOrgScimConnections(organizationId);
+ setScimConnections(list);
+ setMessage("SCIM connection created.");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to create SCIM connection.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const deleteScim = async (connection: OrgScimConnection) => {
+ if (
+ !window.confirm(
+ `Delete SCIM connection ${connection.name}? This stops directory provisioning but does not deactivate existing members.`
+ )
+ )
+ return;
+ setSubmitting(true);
+ setError(null);
+ try {
+ await apiService.deleteOrgScimConnection(organizationId, connection.id);
+ const list = await apiService.getOrgScimConnections(organizationId);
+ setScimConnections(list);
+ setMessage("SCIM connection deleted.");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to delete SCIM connection.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const loadScimTokens = async (connection: OrgScimConnection) => {
+ setError(null);
+ try {
+ const tokens = await apiService.getOrgScimTokens(organizationId, connection.id);
+ setScimTokensByConnection((current) => ({ ...current, [connection.id]: tokens }));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to load tokens.");
+ }
+ };
+
+ const createScimToken = async (connection: OrgScimConnection) => {
+ setSubmitting(true);
+ setError(null);
+ setRevealedToken(null);
+ try {
+ const token = await apiService.createOrgScimToken(organizationId, connection.id);
+ if (token.token) {
+ setRevealedToken({ connectionId: connection.id, value: token.token });
+ }
+ await loadScimTokens(connection);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to create token.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const revokeScimToken = async (connection: OrgScimConnection, token: OrgScimToken) => {
+ if (!window.confirm("Revoke this SCIM bearer token? The IdP using it will stop syncing."))
+ return;
+ setSubmitting(true);
+ setError(null);
+ try {
+ await apiService.deleteOrgScimToken(organizationId, connection.id, token.id);
+ if (revealedToken?.connectionId === connection.id) setRevealedToken(null);
+ await loadScimTokens(connection);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to revoke token.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const scimBaseUrl = (connection: OrgScimConnection) =>
+ connection.baseUrl || `${window.location.origin}/scim/v2`;
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (managed === false) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {error ?
{error}
: null}
+ {message ?
{message}
: null}
+
+
+ New connection
+
+ }
+ >
+
+ {federationConnections.length === 0 && !creatingFederation ? (
+
+ ) : (
+ federationConnections.map((connection) => (
+
+
+
+ {connection.name}
+
+ {connection.issuer || connection.discoveryUrl}
+
+
+
+
+ {connection.enabled ? "Enabled" : "Disabled"}
+
+ selectFederation(connection)}
+ >
+ {selectedFederationId === connection.id ? "Editing" : "Edit"}
+
+ deleteFederation(connection)}
+ >
+ Delete
+
+
+
+
+ ))
+ )}
+
+
+ {creatingFederation || selectedFederationId ? (
+
+ ) : null}
+
+ {selectedFederationId && !creatingFederation ? (
+
+
Domains
+
+ Add the email domains your members use. Each domain must be verified with a DNS TXT
+ record before DarkAuth will route those users to this connection. Routing will not
+ activate until verification succeeds.
+
+
+
+ {domains.length === 0 ? (
+
No domains added yet.
+ ) : (
+ domains.map((domain) => (
+
+
+
+ {domain.domain}
+ {domain.lastCheckedAt ? (
+
+ Last checked {new Date(domain.lastCheckedAt).toLocaleString()}
+
+ ) : null}
+
+
+
+ {domain.verificationStatus}
+
+ verifyDomain(domain)}
+ >
+ Check / retry verification
+
+ removeDomain(domain)}
+ >
+ Remove
+
+
+
+ {domain.verificationStatus !== "verified" ? (
+
+
+ Add the following DNS TXT record at your domain registrar, then click
+ “Check / retry verification”. Routing for this domain will not activate
+ until verification succeeds.
+
+
+ {recordValue(domain) ? (
+
+ ) : (
+
+ The exact record value will appear here once the domain is created.
+
+ )}
+
+ ) : null}
+
+ ))
+ )}
+
+
+ ) : null}
+
+
+
+
+
+
+ Deactivating a member through SCIM suspends or removes their membership in this
+ organization only. It does not delete their root DarkAuth account, which may belong to
+ other organizations.
+
+
+
+ {scimConnections.length === 0 ? (
+
+ ) : (
+ scimConnections.map((connection) => {
+ const tokens = scimTokensByConnection[connection.id];
+ return (
+
+
+
+ {connection.name}
+
+
+
+ {connection.enabled === false ? "Disabled" : "Enabled"}
+
+ deleteScim(connection)}
+ >
+ Delete
+
+
+
+
+
+
+
+
+ loadScimTokens(connection)}
+ >
+ {tokens ? "Refresh tokens" : "View tokens"}
+
+ createScimToken(connection)}
+ >
+ Create bearer token
+
+
+
+ {revealedToken?.connectionId === connection.id ? (
+
+
+ Copy this bearer token now. It is shown only once and cannot be retrieved
+ again.
+
+
+
+ ) : null}
+
+ {tokens ? (
+ tokens.length === 0 ? (
+
No tokens for this connection.
+ ) : (
+
+ {tokens.map((token) => (
+
+
+
+ {token.prefix ? `${token.prefix}…` : token.id}
+
+
+ {token.status || "active"}
+ {token.lastUsedAt
+ ? ` · last used ${new Date(token.lastUsedAt).toLocaleString()}`
+ : ""}
+
+
+
revokeScimToken(connection, token)}
+ >
+ Revoke
+
+
+ ))}
+
+ )
+ ) : null}
+
+
+ );
+ })
+ )}
+
+
+
+ );
+}
diff --git a/packages/user-ui/src/components/OrganizationDetail.module.css b/packages/user-ui/src/components/OrganizationDetail.module.css
new file mode 100644
index 00000000..dd7ea918
--- /dev/null
+++ b/packages/user-ui/src/components/OrganizationDetail.module.css
@@ -0,0 +1,155 @@
+.tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--da-space-2);
+}
+
+.tab {
+ min-height: 40px;
+ padding: 0.625rem 0.875rem;
+ border: 1px solid var(--da-color-border);
+ border-radius: var(--da-control-radius);
+ background: transparent;
+ color: var(--da-color-text-muted);
+ font: inherit;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.tabActive {
+ border-color: color-mix(in srgb, var(--da-color-action) 42%, var(--da-color-border));
+ background: color-mix(in srgb, var(--da-color-action) 9%, transparent);
+ color: var(--da-color-text);
+}
+
+.grid {
+ display: grid;
+ gap: var(--da-space-4);
+}
+
+.member {
+ display: grid;
+ gap: var(--da-space-3);
+ padding: var(--da-space-4);
+ border: 1px solid var(--da-color-border);
+ border-radius: var(--da-surface-radius);
+}
+
+.memberHeader {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--da-space-3);
+ min-width: 0;
+}
+
+.memberTitle {
+ display: grid;
+ min-width: 0;
+ gap: 0.125rem;
+}
+
+.memberTitle strong,
+.connection strong {
+ color: var(--da-color-text);
+ overflow-wrap: anywhere;
+}
+
+.memberTitle small,
+.muted {
+ color: var(--da-color-text-muted);
+ font-size: 0.875rem;
+ overflow-wrap: anywhere;
+}
+
+.roleList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--da-space-2);
+}
+
+.role {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ min-height: 1.75rem;
+ padding: 0.25rem 0.625rem;
+ border: 1px solid var(--da-color-border);
+ border-radius: 999px;
+ background: var(--da-color-surface-raised);
+ color: var(--da-color-text);
+ font-size: 0.8125rem;
+ font-weight: 800;
+}
+
+.role button {
+ border: 0;
+ background: transparent;
+ color: var(--da-color-danger);
+ font: inherit;
+ cursor: pointer;
+}
+
+.inlineForm {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--da-space-2);
+ align-items: flex-end;
+}
+
+.inlineForm label {
+ display: grid;
+ gap: var(--da-space-2);
+ min-width: min(100%, 18rem);
+ color: var(--da-color-text);
+ font-size: 0.875rem;
+ font-weight: 800;
+}
+
+.inlineForm input,
+.inlineForm select {
+ min-height: 44px;
+ padding: 0.75rem 0.875rem;
+ border: 1px solid var(--da-color-border);
+ border-radius: var(--da-control-radius);
+ background: var(--da-color-surface);
+ color: var(--da-color-text);
+}
+
+.connectionGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: var(--da-space-4);
+}
+
+.connection {
+ display: grid;
+ gap: var(--da-space-2);
+ padding: var(--da-space-4);
+ border: 1px solid var(--da-color-border);
+ border-radius: var(--da-surface-radius);
+ background: var(--da-color-surface-raised);
+}
+
+.error,
+.success {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 800;
+}
+
+.error {
+ color: var(--da-color-danger);
+}
+
+.success {
+ color: var(--da-color-success);
+}
+
+@media (max-width: 640px) {
+ .memberHeader,
+ .inlineForm {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
diff --git a/packages/user-ui/src/components/OrganizationDetail.test.js b/packages/user-ui/src/components/OrganizationDetail.test.js
new file mode 100644
index 00000000..ba8d1e90
--- /dev/null
+++ b/packages/user-ui/src/components/OrganizationDetail.test.js
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const source = readFileSync(resolve(here, "OrganizationDetail.tsx"), "utf8");
+const apiSource = readFileSync(resolve(here, "../services/api.ts"), "utf8");
+const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
+
+test("organization detail route and component are wired", () => {
+ assert.notEqual(appSource.indexOf('path="/organizations/:organizationId"'), -1);
+ assert.notEqual(appSource.indexOf("OrganizationDetail"), -1);
+ assert.notEqual(source.indexOf("Enterprise Connections"), -1);
+ assert.notEqual(source.indexOf("Members"), -1);
+});
+
+test("organization detail uses user organization management endpoints", () => {
+ assert.notEqual(apiSource.indexOf("getOrganizationMembers"), -1);
+ assert.notEqual(apiSource.indexOf("createOrganizationInvite"), -1);
+ assert.notEqual(apiSource.indexOf("assignOrganizationMemberRoles"), -1);
+ assert.notEqual(apiSource.indexOf("removeOrganizationMemberRole"), -1);
+ assert.notEqual(apiSource.indexOf("getAssignableOrganizationRoles"), -1);
+ assert.notEqual(apiSource.indexOf("removeOrganizationMember"), -1);
+ assert.notEqual(apiSource.indexOf("leaveOrganization"), -1);
+ assert.notEqual(apiSource.indexOf("deleteOrganization"), -1);
+ assert.notEqual(source.indexOf("apiService.getOrganizationMembers"), -1);
+ assert.notEqual(source.indexOf("apiService.createOrganizationInvite"), -1);
+ assert.notEqual(source.indexOf("apiService.assignOrganizationMemberRoles"), -1);
+ assert.notEqual(source.indexOf("apiService.removeOrganizationMemberRole"), -1);
+ assert.notEqual(source.indexOf("apiService.removeOrganizationMember"), -1);
+ assert.notEqual(source.indexOf("apiService.leaveOrganization"), -1);
+ assert.notEqual(source.indexOf("apiService.deleteOrganization"), -1);
+});
diff --git a/packages/user-ui/src/components/OrganizationDetail.tsx b/packages/user-ui/src/components/OrganizationDetail.tsx
new file mode 100644
index 00000000..c05e1cc6
--- /dev/null
+++ b/packages/user-ui/src/components/OrganizationDetail.tsx
@@ -0,0 +1,463 @@
+import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import apiService, {
+ type ConnectedIdentityResponse,
+ type OrganizationMember,
+ type OrganizationRole,
+ type UserOrganization,
+} from "../services/api";
+import Button from "./Button";
+import EnterpriseConnections from "./EnterpriseConnections";
+import styles from "./OrganizationDetail.module.css";
+import { cx, EmptyState, PortalHeader, PortalPage, PortalSection, StatusPill } from "./Portal";
+import UserLayout from "./UserLayout";
+
+interface OrganizationDetailProps {
+ sessionData: {
+ sub: string;
+ name?: string;
+ email?: string;
+ organizationId?: string;
+ };
+ onLogout: () => void;
+ onOrganizationChanged?: (organization: {
+ organizationId: string;
+ organizationSlug?: string;
+ }) => void;
+}
+
+type Tab = "members" | "roles" | "enterprise" | "security";
+
+function identityName(identity: ConnectedIdentityResponse) {
+ return identity.connectionName || identity.connection_name || identity.issuer || "Enterprise SSO";
+}
+
+export default function OrganizationDetail({
+ sessionData,
+ onLogout,
+ onOrganizationChanged,
+}: OrganizationDetailProps) {
+ const { organizationId = "" } = useParams<{ organizationId: string }>();
+ const navigate = useNavigate();
+ const [tab, setTab] = useState("members");
+ const [organization, setOrganization] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [assignableRoles, setAssignableRoles] = useState([]);
+ const [connectedIdentities, setConnectedIdentities] = useState([]);
+ const [inviteEmail, setInviteEmail] = useState("");
+ const [inviteRoleIds, setInviteRoleIds] = useState([]);
+ const [roleSelectionByMember, setRoleSelectionByMember] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [message, setMessage] = useState(null);
+
+ const loadData = useCallback(async () => {
+ if (!organizationId) {
+ setError("Organization is required.");
+ setLoading(false);
+ return;
+ }
+ try {
+ setLoading(true);
+ setError(null);
+ const [orgResponse, memberResponse, roles, identities] = await Promise.all([
+ apiService.getOrganization(organizationId),
+ apiService.getOrganizationMembers(organizationId).catch(() => ({ members: [] })),
+ apiService.getAssignableOrganizationRoles(organizationId).catch(() => []),
+ apiService.getConnectedIdentities().catch(() => []),
+ ]);
+ setOrganization(orgResponse.organization);
+ setMembers(memberResponse.members || []);
+ setAssignableRoles(roles);
+ setConnectedIdentities(identities);
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : "Unable to load organization.");
+ } finally {
+ setLoading(false);
+ }
+ }, [organizationId]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ const current = organization?.organizationId === sessionData.organizationId;
+ const roleOptionsById = useMemo(
+ () => new Map(assignableRoles.map((role) => [role.id, role])),
+ [assignableRoles]
+ );
+
+ const switchToOrganization = async () => {
+ if (!organization) return;
+ try {
+ setSubmitting(true);
+ setError(null);
+ const response = await apiService.setSessionOrganization(organization.organizationId);
+ onOrganizationChanged?.(response);
+ } catch (switchError) {
+ setError(
+ switchError instanceof Error ? switchError.message : "Unable to switch organization."
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const createInvite = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!organization || !inviteEmail.trim()) return;
+ try {
+ setSubmitting(true);
+ setError(null);
+ setMessage(null);
+ await apiService.createOrganizationInvite(organization.organizationId, {
+ email: inviteEmail.trim(),
+ roleIds: inviteRoleIds,
+ });
+ setInviteEmail("");
+ setInviteRoleIds([]);
+ setMessage("Invite created.");
+ } catch (inviteError) {
+ setError(inviteError instanceof Error ? inviteError.message : "Unable to create invite.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const assignRole = async (member: OrganizationMember) => {
+ if (!organization) return;
+ const roleId = roleSelectionByMember[member.membershipId];
+ if (!roleId) return;
+ try {
+ setSubmitting(true);
+ setError(null);
+ await apiService.assignOrganizationMemberRoles(
+ organization.organizationId,
+ member.membershipId,
+ [roleId]
+ );
+ setRoleSelectionByMember((current) => ({ ...current, [member.membershipId]: "" }));
+ await loadData();
+ } catch (assignError) {
+ setError(assignError instanceof Error ? assignError.message : "Unable to assign role.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const removeRole = async (member: OrganizationMember, role: OrganizationRole) => {
+ if (!organization) return;
+ try {
+ setSubmitting(true);
+ setError(null);
+ await apiService.removeOrganizationMemberRole(
+ organization.organizationId,
+ member.membershipId,
+ role.id
+ );
+ await loadData();
+ } catch (removeError) {
+ setError(removeError instanceof Error ? removeError.message : "Unable to remove role.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const removeMember = async (member: OrganizationMember) => {
+ if (!organization) return;
+ const label = member.email || member.userSub;
+ if (!window.confirm(`Remove ${label} from ${organization.name}?`)) return;
+ try {
+ setSubmitting(true);
+ setError(null);
+ await apiService.removeOrganizationMember(organization.organizationId, member.membershipId);
+ await loadData();
+ } catch (removeError) {
+ setError(removeError instanceof Error ? removeError.message : "Unable to remove member.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const leaveOrganization = async () => {
+ if (!organization) return;
+ if (!window.confirm(`Leave ${organization.name}?`)) return;
+ try {
+ setSubmitting(true);
+ setError(null);
+ await apiService.leaveOrganization(organization.organizationId);
+ navigate("/profile", { replace: true });
+ } catch (leaveError) {
+ setError(leaveError instanceof Error ? leaveError.message : "Unable to leave organization.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const deleteOrganization = async () => {
+ if (!organization) return;
+ if (!window.confirm(`Delete ${organization.name}? This action cannot be undone.`)) return;
+ try {
+ setSubmitting(true);
+ setError(null);
+ await apiService.deleteOrganization(organization.organizationId);
+ navigate("/profile", { replace: true });
+ } catch (deleteError) {
+ setError(
+ deleteError instanceof Error ? deleteError.message : "Unable to delete organization."
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ navigate("/profile")}>
+ Back
+
+ {organization && !current ? (
+
+ Use organization
+
+ ) : null}
+ >
+ }
+ />
+
+ {error ? {error}
: null}
+ {message ? {message}
: null}
+
+
+ {(["members", "roles", "enterprise", "security"] as Tab[]).map((item) => (
+ setTab(item)}
+ >
+ {item === "enterprise"
+ ? "Enterprise Connections"
+ : item.slice(0, 1).toUpperCase() + item.slice(1)}
+
+ ))}
+
+
+ {loading ? (
+
+ ) : !organization ? (
+
+ ) : tab === "members" ? (
+
+
+
+
+ {members.length === 0 ? (
+
+ ) : (
+ members.map((member) => (
+
+
+
+ {member.email || member.userSub}
+ {member.name || member.userSub}
+
+
+
+ {member.status}
+
+ {member.userSub !== sessionData.sub ? (
+ removeMember(member)}
+ >
+ Remove
+
+ ) : null}
+
+
+
+ {member.roles.length === 0 ? (
+ No roles
+ ) : (
+ member.roles.map((role) => (
+
+ {role.name}
+ removeRole(member, role)}
+ >
+ Remove
+
+
+ ))
+ )}
+
+ {assignableRoles.length > 0 ? (
+
+
+ Add role
+
+ setRoleSelectionByMember((current) => ({
+ ...current,
+ [member.membershipId]: event.target.value,
+ }))
+ }
+ >
+ Select role
+ {assignableRoles.map((role) => (
+
+ {role.name}
+
+ ))}
+
+
+ assignRole(member)}
+ >
+ Add
+
+
+ ) : null}
+
+ ))
+ )}
+
+
+ ) : tab === "roles" ? (
+
+
+ {assignableRoles.length === 0 ? (
+ No assignable roles are visible.
+ ) : (
+ assignableRoles.map((role) => (
+
+ {role.name}
+
+ ))
+ )}
+
+
+ ) : tab === "enterprise" ? (
+
+ {connectedIdentities.length > 0 ? (
+
+
+ Your connected identities
+ {connectedIdentities.map((identity) => (
+
+ {identityName(identity)}
+
+ ))}
+
+
+ ) : null}
+
+
+ ) : (
+
+
+
+ Active session
+
+ {current ? "Current" : "Available"}
+
+
+
+
Organization actions
+
+
+ Leave
+
+
+ Delete
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/user-ui/src/components/OtpSetupView.tsx b/packages/user-ui/src/components/OtpSetupView.tsx
index 38c1911d..261731c7 100644
--- a/packages/user-ui/src/components/OtpSetupView.tsx
+++ b/packages/user-ui/src/components/OtpSetupView.tsx
@@ -11,7 +11,7 @@ export default function OtpSetupView({
sessionData,
onLogout,
}: {
- sessionData: { sub: string; name?: string; email?: string };
+ sessionData: { sub: string; name?: string; email?: string; organizationSlug?: string };
onLogout: () => void;
}) {
const branding = useBranding();
@@ -60,6 +60,7 @@ export default function OtpSetupView({
navigate("/security/password")}
onManageSecurity={() => navigate("/security")}
onLogout={onLogout}
diff --git a/packages/user-ui/src/components/OtpVerifyView.tsx b/packages/user-ui/src/components/OtpVerifyView.tsx
index 305e693e..fa4ecb8a 100644
--- a/packages/user-ui/src/components/OtpVerifyView.tsx
+++ b/packages/user-ui/src/components/OtpVerifyView.tsx
@@ -17,8 +17,12 @@ export default function OtpVerifyView() {
useEffect(() => {
(async () => {
+ let statusRequired = false;
+ let statusChecked = false;
try {
const s = await api.getOtpStatus();
+ statusChecked = true;
+ statusRequired = !!s.required;
if (!s.enabled) {
window.location.replace("/otp/setup?forced=1");
return;
@@ -26,7 +30,10 @@ export default function OtpVerifyView() {
} catch {}
try {
const session = await api.getSession();
- if (!session.otpRequired) navigate("/apps");
+ const requiresOtp = !!session.otpRequired || statusRequired;
+ if (session.otpVerified || (statusChecked && !requiresOtp)) {
+ navigate("/apps", { replace: true });
+ }
} catch {}
})();
}, [navigate]);
diff --git a/packages/user-ui/src/components/Profile.module.css b/packages/user-ui/src/components/Profile.module.css
index 51dd061f..638691e2 100644
--- a/packages/user-ui/src/components/Profile.module.css
+++ b/packages/user-ui/src/components/Profile.module.css
@@ -132,7 +132,7 @@
.currentOrg {
display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--da-space-4);
padding: var(--da-space-4);
@@ -260,6 +260,12 @@
}
}
+@media (max-width: 639px) {
+ .currentOrg {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+}
+
@media (max-width: 640px) {
.row,
.codeValue,
diff --git a/packages/user-ui/src/components/Profile.test.js b/packages/user-ui/src/components/Profile.test.js
index a2e87ef1..aac4b592 100644
--- a/packages/user-ui/src/components/Profile.test.js
+++ b/packages/user-ui/src/components/Profile.test.js
@@ -35,3 +35,23 @@ test("profile changes can refresh the portal header session state", () => {
assert.notEqual(appSource.indexOf("onProfileChanged={updateSessionProfile}"), -1);
assert.notEqual(source.indexOf("onProfileChanged"), -1);
});
+
+test("profile keeps organization create, hosted switch, and detail entry points", () => {
+ assert.notEqual(source.indexOf(".getOrganizations"), -1);
+ assert.notEqual(source.indexOf("apiService.createOrganization"), -1);
+ assert.notEqual(source.indexOf("apiService.setSessionOrganization"), -1);
+ assert.notEqual(
+ source.indexOf("const canSwitchOrganizations = activeOrganizations.length > 1"),
+ -1
+ );
+ assert.notEqual(source.indexOf('navigate("/switch-org")'), -1);
+ assert.notEqual(source.indexOf("{canSwitchOrganizations ?"), -1);
+ assert.notEqual(source.indexOf("/organizations/${encodeURIComponent"), -1);
+});
+
+test("profile and portal show the current active organization", () => {
+ assert.notEqual(source.indexOf("Active organization"), -1);
+ assert.notEqual(source.indexOf('Current '), -1);
+ assert.notEqual(source.indexOf("organizationLabel={organizationLabel}"), -1);
+ assert.notEqual(appSource.indexOf("organizationSlug: session.organizationSlug"), -1);
+});
diff --git a/packages/user-ui/src/components/Profile.tsx b/packages/user-ui/src/components/Profile.tsx
index 821a190f..bd263069 100644
--- a/packages/user-ui/src/components/Profile.tsx
+++ b/packages/user-ui/src/components/Profile.tsx
@@ -151,7 +151,8 @@ export default function Profile({
const currentOrganization = activeOrganizations.find(
(organization) => organization.organizationId === sessionData.organizationId
);
- const primaryOrganization = currentOrganization || activeOrganizations[0] || null;
+ const canSwitchOrganizations = activeOrganizations.length > 1;
+ const organizationLabel = currentOrganization?.name || sessionData.organizationSlug || null;
const activeEmail = profile.email || sessionData.email || "";
const signInEmail = profile.signInEmail || activeEmail;
const signInEmailDiffers =
@@ -288,6 +289,10 @@ export default function Profile({
}
};
+ const openSwitchOrg = () => {
+ if (canSwitchOrganizations) navigate("/switch-org");
+ };
+
const createOrganization = async () => {
const name = newOrgName.trim();
const slug = newOrgSlug.trim();
@@ -322,13 +327,14 @@ export default function Profile({
@@ -476,8 +482,8 @@ export default function Profile({
}
actions={
<>
- {activeOrganizations.length > 1 ? (
- navigate("/switch-org")}>
+ {canSwitchOrganizations ? (
+
Switch
) : null}
@@ -488,28 +494,31 @@ export default function Profile({
>
}
>
- {primaryOrganization ? (
+ {currentOrganization ? (
- {primaryOrganization.name.slice(0, 1).toUpperCase()}
+ {currentOrganization.name.slice(0, 1).toUpperCase()}
- Default organization
- {primaryOrganization.name}
- {primaryOrganization.slug ? {primaryOrganization.slug} : null}
+ Active organization
+ {currentOrganization.name}
+ {currentOrganization.slug ? {currentOrganization.slug} : null}
-
Current
+
+ navigate(
+ `/organizations/${encodeURIComponent(currentOrganization.organizationId)}`
+ )
}
>
- {primaryOrganization.organizationId === sessionData.organizationId
- ? "Current"
- : "Available"}
-
+ Manage
+
+ ) : activeOrganizations.length > 0 ? (
+ No active organization is selected.
) : null}
{showCreateOrg ? (
@@ -566,14 +575,7 @@ export default function Profile({
className={cx(styles.orgItem, current && styles.orgItemCurrent)}
key={organization.organizationId}
onClick={() =>
- current
- ? undefined
- : apiService
- .setSessionOrganization(organization.organizationId)
- .then((nextSession) => {
- onOrganizationChanged?.(nextSession);
- navigate("/profile", { replace: true });
- })
+ navigate(`/organizations/${encodeURIComponent(organization.organizationId)}`)
}
>
@@ -581,7 +583,7 @@ export default function Profile({
{organization.slug ? {organization.slug} : null}
- {current ? "Default" : "Use"}
+ {current ? "Current" : "Available"}
);
diff --git a/packages/user-ui/src/components/SettingsSecurityView.tsx b/packages/user-ui/src/components/SettingsSecurityView.tsx
index 967c3b66..f9c371fd 100644
--- a/packages/user-ui/src/components/SettingsSecurityView.tsx
+++ b/packages/user-ui/src/components/SettingsSecurityView.tsx
@@ -13,6 +13,7 @@ type SettingsSessionData = {
name?: string;
email?: string;
keyState?: KeyState;
+ organizationSlug?: string;
};
function resolveKeyState(sessionData: SettingsSessionData): KeyState {
@@ -41,6 +42,7 @@ export default function SettingsSecurityView({
navigate("/security/password")}
onManageSecurity={() => navigate("/security")}
onLogout={onLogout}
diff --git a/packages/user-ui/src/components/SwitchOrg.test.js b/packages/user-ui/src/components/SwitchOrg.test.js
new file mode 100644
index 00000000..1d7d4948
--- /dev/null
+++ b/packages/user-ui/src/components/SwitchOrg.test.js
@@ -0,0 +1,29 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const source = readFileSync(resolve(here, "SwitchOrg.tsx"), "utf8");
+const apiSource = readFileSync(resolve(here, "../services/api.ts"), "utf8");
+const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
+
+test("hosted organization switch screen stays at /switch-org", () => {
+ assert.notEqual(appSource.indexOf('path="/switch-org"'), -1);
+ assert.notEqual(appSource.indexOf(" {
+ assert.notEqual(source.indexOf('searchParams.get("return_to")'), -1);
+ assert.notEqual(source.indexOf('searchParams.get("client_id")'), -1);
+ assert.notEqual(source.indexOf('searchParams.get("organization_id")'), -1);
+ assert.notEqual(source.indexOf("requestedOrganizationId || sessionData.organizationId"), -1);
+ assert.notEqual(apiSource.indexOf("return_to: options.returnTo"), -1);
+ assert.notEqual(apiSource.indexOf("client_id: options.clientId"), -1);
+});
+
+test("hosted organization switch explains connected app active organization", () => {
+ assert.notEqual(source.indexOf("active organization connected apps should use"), -1);
+ assert.notEqual(source.indexOf("make active for connected apps"), -1);
+});
diff --git a/packages/user-ui/src/components/SwitchOrg.tsx b/packages/user-ui/src/components/SwitchOrg.tsx
index 54108608..637bad9f 100644
--- a/packages/user-ui/src/components/SwitchOrg.tsx
+++ b/packages/user-ui/src/components/SwitchOrg.tsx
@@ -119,7 +119,7 @@ export default function SwitchOrg({ sessionData, onOrganizationChanged }: Switch
Switch organization
- Choose the organization you want to use for your current session.
+ Choose the active organization connected apps should use for this session.
@@ -147,7 +147,7 @@ export default function SwitchOrg({ sessionData, onOrganizationChanged }: Switch
) : (
- Select an active organization.
+ Select the organization to make active for connected apps.
{activeOrganizations.map((organization) => (
{organization.name}
{organization.slug
- ? `Organization: ${organization.slug}`
- : "Active organization"}
+ ? `Connected apps will use ${organization.slug}`
+ : "Connected apps will use this organization"}
diff --git a/packages/user-ui/src/components/UserLayout.module.css b/packages/user-ui/src/components/UserLayout.module.css
index 7a1dae84..a0d74e15 100644
--- a/packages/user-ui/src/components/UserLayout.module.css
+++ b/packages/user-ui/src/components/UserLayout.module.css
@@ -117,6 +117,45 @@
gap: 0.5rem;
}
+.orgIndicator {
+ display: none;
+ min-width: 0;
+ max-width: 12rem;
+ padding: 0.375rem 0.625rem;
+ border: 1px solid color-mix(in srgb, var(--da-color-action) 30%, var(--da-color-border));
+ border-radius: 0.5rem;
+ background: color-mix(in srgb, var(--da-color-action) 8%, transparent);
+ color: var(--da-color-text);
+ text-decoration: none;
+}
+
+.orgIndicator:hover {
+ background: color-mix(in srgb, var(--da-color-action) 12%, var(--da-color-surface));
+ text-decoration: none;
+}
+
+.orgIndicator span,
+.orgIndicator strong {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.orgIndicator span {
+ color: var(--da-color-text-muted);
+ font-size: 0.6875rem;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.orgIndicator strong {
+ color: var(--da-color-text);
+ font-size: 0.8125rem;
+ font-weight: 800;
+}
+
.userButton {
display: inline-flex;
align-items: center;
@@ -196,6 +235,7 @@
display: none;
}
+ .orgIndicator,
.userCopy {
display: grid;
}
diff --git a/packages/user-ui/src/components/UserLayout.tsx b/packages/user-ui/src/components/UserLayout.tsx
index 3c9dbebc..a1245b85 100644
--- a/packages/user-ui/src/components/UserLayout.tsx
+++ b/packages/user-ui/src/components/UserLayout.tsx
@@ -8,13 +8,19 @@ import styles from "./UserLayout.module.css";
interface UserLayoutProps {
userName?: string | null;
userEmail?: string | null;
+ organizationLabel?: string | null;
onChangePassword?: () => void;
onManageSecurity?: () => void;
onLogout?: () => void;
children: ReactNode;
}
-export default function UserLayout({ userName, userEmail, children }: UserLayoutProps) {
+export default function UserLayout({
+ userName,
+ userEmail,
+ organizationLabel,
+ children,
+}: UserLayoutProps) {
const branding = useBranding();
const logoUrl = branding.getLogoUrl();
const isDefaultLogo = branding.isDefaultLogoUrl(logoUrl);
@@ -55,6 +61,16 @@ export default function UserLayout({ userName, userEmail, children }: UserLayout
{renderNav(styles.desktopNav)}
+ {organizationLabel ? (
+
+
Active org
+
{organizationLabel}
+
+ ) : null}
{(userName || userEmail) && (
diff --git a/packages/user-ui/src/services/api.ts b/packages/user-ui/src/services/api.ts
index e43417c8..ea4c5f7a 100644
--- a/packages/user-ui/src/services/api.ts
+++ b/packages/user-ui/src/services/api.ts
@@ -129,6 +129,30 @@ export interface UserOrganization {
roles?: Array<{ id?: string; key?: string; name?: string }>;
}
+export interface OrganizationRole {
+ id: string;
+ key: string;
+ name: string;
+ description?: string | null;
+ assignable?: boolean;
+}
+
+export interface OrganizationMember {
+ membershipId: string;
+ userSub: string;
+ status: string;
+ email?: string | null;
+ name?: string | null;
+ roles: OrganizationRole[];
+}
+
+export interface OrganizationInvite {
+ id: string;
+ email: string;
+ expiresAt?: string;
+ token?: string;
+}
+
export interface SessionOrganizationResponse {
organizationId: string;
organizationSlug?: string;
@@ -298,6 +322,74 @@ export interface FederationConnectionRoute {
enabled: boolean;
}
+export interface OrgFederationConnection {
+ id: string;
+ organizationId?: string;
+ name: string;
+ protocol?: string;
+ enabled: boolean;
+ discoveryUrl?: string | null;
+ issuer?: string | null;
+ clientId?: string | null;
+ hasClientSecret?: boolean;
+ scopes?: string[];
+ emailClaim?: string | null;
+ nameClaim?: string | null;
+ subjectClaim?: string | null;
+ jitProvisioning?: boolean;
+ membershipOnAuthentication?: boolean;
+ requireScimPreProvisioning?: boolean;
+ callbackUrl?: string | null;
+ createdAt?: string | null;
+ updatedAt?: string | null;
+}
+
+export interface OrgFederationConnectionInput {
+ name: string;
+ enabled?: boolean;
+ discoveryUrl?: string;
+ clientId?: string;
+ clientSecret?: string;
+ scopes?: string[];
+ emailClaim?: string;
+ nameClaim?: string;
+ subjectClaim?: string;
+ jitProvisioning?: boolean;
+ membershipOnAuthentication?: boolean;
+ requireScimPreProvisioning?: boolean;
+}
+
+export interface OrgFederationDomain {
+ id: string;
+ domain: string;
+ verificationStatus: string;
+ recordName?: string | null;
+ recordValue?: string | null;
+ lastCheckedAt?: string | null;
+ verifiedAt?: string | null;
+}
+
+export interface OrgScimConnection {
+ id: string;
+ organizationId?: string;
+ name: string;
+ enabled?: boolean;
+ baseUrl?: string | null;
+ deprovisionAction?: string | null;
+ createdAt?: string | null;
+ updatedAt?: string | null;
+}
+
+export interface OrgScimToken {
+ id: string;
+ prefix?: string | null;
+ status?: string | null;
+ createdAt?: string | null;
+ lastUsedAt?: string | null;
+ expiresAt?: string | null;
+ token?: string | null;
+}
+
export interface PasswordResetRequestResponse {
success: boolean;
message: string;
@@ -579,6 +671,10 @@ class ApiService {
return this.request("/organizations");
}
+ async getOrganization(organizationId: string): Promise<{ organization: UserOrganization }> {
+ return this.request(`/organizations/${encodeURIComponent(organizationId)}`);
+ }
+
async createOrganization(request: {
name: string;
slug?: string;
@@ -589,6 +685,75 @@ class ApiService {
});
}
+ async getOrganizationMembers(organizationId: string): Promise<{ members: OrganizationMember[] }> {
+ return this.request(`/organizations/${encodeURIComponent(organizationId)}/members`);
+ }
+
+ async createOrganizationInvite(
+ organizationId: string,
+ request: { email: string; roleIds?: string[]; expiresAt?: string }
+ ): Promise<{ invite: OrganizationInvite }> {
+ return this.request(`/organizations/${encodeURIComponent(organizationId)}/invites`, {
+ method: "POST",
+ body: JSON.stringify(request),
+ });
+ }
+
+ async assignOrganizationMemberRoles(
+ organizationId: string,
+ memberId: string,
+ roleIds: string[]
+ ): Promise<{ assigned: OrganizationRole[] }> {
+ const path = `/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(
+ memberId
+ )}/roles`;
+ return this.request(path, {
+ method: "POST",
+ body: JSON.stringify({ roleIds }),
+ });
+ }
+
+ async removeOrganizationMemberRole(
+ organizationId: string,
+ memberId: string,
+ roleId: string
+ ): Promise<{ success: boolean }> {
+ const path = `/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(
+ memberId
+ )}/roles/${encodeURIComponent(roleId)}`;
+ return this.request(path, { method: "DELETE" });
+ }
+
+ async getAssignableOrganizationRoles(organizationId: string): Promise
{
+ const data = await this.request<{ roles?: OrganizationRole[] } | OrganizationRole[]>(
+ `/organizations/${encodeURIComponent(organizationId)}/roles/assignable`
+ );
+ return Array.isArray(data) ? data : data.roles || [];
+ }
+
+ async removeOrganizationMember(
+ organizationId: string,
+ memberId: string
+ ): Promise<{ success: boolean }> {
+ const path = `/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(
+ memberId
+ )}`;
+ return this.request(path, { method: "DELETE" });
+ }
+
+ async leaveOrganization(organizationId: string): Promise<{ success: boolean }> {
+ return this.request(`/organizations/${encodeURIComponent(organizationId)}/leave`, {
+ method: "POST",
+ });
+ }
+
+ async deleteOrganization(organizationId: string): Promise<{ success: boolean }> {
+ return this.request(`/organizations/${encodeURIComponent(organizationId)}`, {
+ method: "DELETE",
+ body: JSON.stringify({ confirm: true }),
+ });
+ }
+
async setSessionOrganization(
organizationId: string,
options: { returnTo?: string; clientId?: string } = {}
@@ -1172,6 +1337,233 @@ class ApiService {
return Array.isArray(data) ? data : data.identities || data.connected_identities || [];
}
+ async getOrgFederationConnections(organizationId: string): Promise {
+ const data = await this.request<
+ { connections?: OrgFederationConnection[] } | OrgFederationConnection[]
+ >(`/organizations/${encodeURIComponent(organizationId)}/federation/connections`);
+ return Array.isArray(data) ? data : data.connections || [];
+ }
+
+ async getOrgFederationConnection(
+ organizationId: string,
+ connectionId: string
+ ): Promise {
+ const data = await this.request<
+ { connection?: OrgFederationConnection } | OrgFederationConnection
+ >(
+ `/organizations/${encodeURIComponent(organizationId)}/federation/connections/${encodeURIComponent(
+ connectionId
+ )}`
+ );
+ return "connection" in data && data.connection
+ ? data.connection
+ : (data as OrgFederationConnection);
+ }
+
+ async createOrgFederationConnection(
+ organizationId: string,
+ input: OrgFederationConnectionInput
+ ): Promise {
+ const data = await this.request<
+ { connection?: OrgFederationConnection } | OrgFederationConnection
+ >(`/organizations/${encodeURIComponent(organizationId)}/federation/connections`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+ return "connection" in data && data.connection
+ ? data.connection
+ : (data as OrgFederationConnection);
+ }
+
+ async updateOrgFederationConnection(
+ organizationId: string,
+ connectionId: string,
+ input: Partial
+ ): Promise {
+ const data = await this.request<
+ { connection?: OrgFederationConnection } | OrgFederationConnection
+ >(
+ `/organizations/${encodeURIComponent(organizationId)}/federation/connections/${encodeURIComponent(
+ connectionId
+ )}`,
+ {
+ method: "PUT",
+ body: JSON.stringify(input),
+ }
+ );
+ return "connection" in data && data.connection
+ ? data.connection
+ : (data as OrgFederationConnection);
+ }
+
+ async deleteOrgFederationConnection(
+ organizationId: string,
+ connectionId: string
+ ): Promise<{ success: boolean }> {
+ return this.request(
+ `/organizations/${encodeURIComponent(organizationId)}/federation/connections/${encodeURIComponent(
+ connectionId
+ )}`,
+ { method: "DELETE" }
+ );
+ }
+
+ async getOrgFederationDomains(
+ organizationId: string,
+ connectionId: string
+ ): Promise {
+ const data = await this.request<{ domains?: OrgFederationDomain[] } | OrgFederationDomain[]>(
+ `/organizations/${encodeURIComponent(organizationId)}/federation/connections/${encodeURIComponent(
+ connectionId
+ )}/domains`
+ );
+ return Array.isArray(data) ? data : data.domains || [];
+ }
+
+ async createOrgFederationDomain(
+ organizationId: string,
+ connectionId: string,
+ domain: string
+ ): Promise {
+ const data = await this.request<{ domain: OrgFederationDomain } | OrgFederationDomain>(
+ `/organizations/${encodeURIComponent(organizationId)}/federation/connections/${encodeURIComponent(
+ connectionId
+ )}/domains`,
+ {
+ method: "POST",
+ body: JSON.stringify({ domain }),
+ }
+ );
+ const wrapped = data as { domain?: OrgFederationDomain };
+ return wrapped.domain && typeof wrapped.domain === "object"
+ ? wrapped.domain
+ : (data as OrgFederationDomain);
+ }
+
+ async deleteOrgFederationDomain(
+ organizationId: string,
+ connectionId: string,
+ domainId: string
+ ): Promise<{ success: boolean }> {
+ return this.request(
+ `/organizations/${encodeURIComponent(organizationId)}/federation/connections/${encodeURIComponent(
+ connectionId
+ )}/domains/${encodeURIComponent(domainId)}`,
+ { method: "DELETE" }
+ );
+ }
+
+ async verifyOrgFederationDomain(
+ organizationId: string,
+ connectionId: string,
+ domainId: string
+ ): Promise {
+ const data = await this.request<{ domain: OrgFederationDomain } | OrgFederationDomain>(
+ `/organizations/${encodeURIComponent(organizationId)}/federation/connections/${encodeURIComponent(
+ connectionId
+ )}/domains/${encodeURIComponent(domainId)}/verify`,
+ { method: "POST" }
+ );
+ const wrapped = data as { domain?: OrgFederationDomain };
+ return wrapped.domain && typeof wrapped.domain === "object"
+ ? wrapped.domain
+ : (data as OrgFederationDomain);
+ }
+
+ async getOrgScimConnections(organizationId: string): Promise {
+ const data = await this.request<{ connections?: OrgScimConnection[] } | OrgScimConnection[]>(
+ `/organizations/${encodeURIComponent(organizationId)}/scim/connections`
+ );
+ return Array.isArray(data) ? data : data.connections || [];
+ }
+
+ async getOrgScimConnection(
+ organizationId: string,
+ connectionId: string
+ ): Promise {
+ const data = await this.request<{ connection?: OrgScimConnection } | OrgScimConnection>(
+ `/organizations/${encodeURIComponent(organizationId)}/scim/connections/${encodeURIComponent(
+ connectionId
+ )}`
+ );
+ return "connection" in data && data.connection ? data.connection : (data as OrgScimConnection);
+ }
+
+ async createOrgScimConnection(organizationId: string, name: string): Promise {
+ const data = await this.request<{ connection?: OrgScimConnection } | OrgScimConnection>(
+ `/organizations/${encodeURIComponent(organizationId)}/scim/connections`,
+ {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ }
+ );
+ return "connection" in data && data.connection ? data.connection : (data as OrgScimConnection);
+ }
+
+ async updateOrgScimConnection(
+ organizationId: string,
+ connectionId: string,
+ input: { name?: string; enabled?: boolean; deprovisionAction?: string }
+ ): Promise {
+ const data = await this.request<{ connection?: OrgScimConnection } | OrgScimConnection>(
+ `/organizations/${encodeURIComponent(organizationId)}/scim/connections/${encodeURIComponent(
+ connectionId
+ )}`,
+ {
+ method: "PUT",
+ body: JSON.stringify(input),
+ }
+ );
+ return "connection" in data && data.connection ? data.connection : (data as OrgScimConnection);
+ }
+
+ async deleteOrgScimConnection(
+ organizationId: string,
+ connectionId: string
+ ): Promise<{ success: boolean }> {
+ return this.request(
+ `/organizations/${encodeURIComponent(organizationId)}/scim/connections/${encodeURIComponent(
+ connectionId
+ )}`,
+ { method: "DELETE" }
+ );
+ }
+
+ async getOrgScimTokens(organizationId: string, connectionId: string): Promise {
+ const data = await this.request<{ tokens?: OrgScimToken[] } | OrgScimToken[]>(
+ `/organizations/${encodeURIComponent(organizationId)}/scim/connections/${encodeURIComponent(
+ connectionId
+ )}/tokens`
+ );
+ return Array.isArray(data) ? data : data.tokens || [];
+ }
+
+ async createOrgScimToken(organizationId: string, connectionId: string): Promise {
+ const data = await this.request<{ token: OrgScimToken } | OrgScimToken>(
+ `/organizations/${encodeURIComponent(organizationId)}/scim/connections/${encodeURIComponent(
+ connectionId
+ )}/tokens`,
+ { method: "POST" }
+ );
+ const wrapped = data as { token?: OrgScimToken | string };
+ return wrapped.token && typeof wrapped.token === "object"
+ ? wrapped.token
+ : (data as OrgScimToken);
+ }
+
+ async deleteOrgScimToken(
+ organizationId: string,
+ connectionId: string,
+ tokenId: string
+ ): Promise<{ success: boolean }> {
+ return this.request(
+ `/organizations/${encodeURIComponent(organizationId)}/scim/connections/${encodeURIComponent(
+ connectionId
+ )}/tokens/${encodeURIComponent(tokenId)}`,
+ { method: "DELETE" }
+ );
+ }
+
async getUserApps(): Promise<{
apps: Array<{
id: string;
diff --git a/specs/2_CORE.md b/specs/2_CORE.md
index a6f9f887..644a0f44 100644
--- a/specs/2_CORE.md
+++ b/specs/2_CORE.md
@@ -357,7 +357,7 @@ All endpoints in this section are served on port `9080` (user). Admin UI/API run
"email": "user@example.com",
"email_verified": true,
"org_id": "...",
- "org_slug": "default",
+ "org_slug": "green-star-bubble-yhgw84",
"roles": ["member"],
"permissions": ["darkauth.users:read"]
}
diff --git a/specs/8_DEFAULT_USER_GROUP.md b/specs/8_DEFAULT_USER_GROUP.md
index 163b0c89..f36c4627 100644
--- a/specs/8_DEFAULT_USER_GROUP.md
+++ b/specs/8_DEFAULT_USER_GROUP.md
@@ -1,5 +1,7 @@
# Default User Group and Group Settings (Enable Login)
+> **Superseded.** This document describes the legacy groups-era model in which every user was forced into a special mandatory `Default` group. Groups have since been replaced by organizations and roles, and the `Default` organization is no longer special. New users receive a personal organization at registration, and "belongs to at least one group" is now "belongs to at least one active organization." See `specs/ORGANISATION_REFACTOR.md` for the authoritative model. The content below is retained for historical context only and should not be implemented as written.
+
## Summary
Introduce a mandatory default group to guarantee every user belongs to at least one group. On fresh installs, create the group `Default` (`key: default`). When a user is created and no groups are assigned, automatically assign them to `default`. Add a per‑group setting “Enable login” (default: true) to control whether membership in that group permits login. Add a new “Settings” section above “Permissions” in Group Create/Edit UI with a toggle for “Enable login”.
diff --git a/specs/ORGANISATION_REFACTOR.md b/specs/ORGANISATION_REFACTOR.md
new file mode 100644
index 00000000..d86721e5
--- /dev/null
+++ b/specs/ORGANISATION_REFACTOR.md
@@ -0,0 +1,960 @@
+# Organisation Refactor
+
+## Summary
+
+DarkAuth should move away from treating `Default` as a special catch-all organization for new users. Organizations are the first user-managed boundary in the product, so every regular user should always have at least one active organization, and every user-created organization should have an owner/admin role assignment chosen by instance configuration rather than hardcoded role keys.
+
+The new model:
+
+- Regular users must belong to at least one active organization.
+- Self-registration creates a first personal organization automatically.
+- Admin-created users can either be assigned to an existing organization or get a new personal organization.
+- SCIM provisioning is organization-owned, not instance-global.
+- `Default` can remain as an ordinary organization, but it has no special runtime behavior.
+- Users can create, manage, leave, and delete organizations in the user UI when they have the right organization permissions.
+- Users cannot create permissions or roles.
+- Organization admins can assign only roles that instance admins have marked assignable.
+- Role defaults are configuration, not hardcoded names like `member` or `org_admin`.
+
+## Why
+
+The current model leaks a migration convenience into product behavior. A shared `Default` organization is useful for bootstrapping old data, but it is a poor default tenant for a B2B/multi-tenant auth system. New users who register are not naturally members of one shared organization controlled by the instance owner. They are usually starting their own account, workspace, family, team, company, or project.
+
+This aligns better with common B2B auth products:
+
+- Clerk recommends membership-required organizations for most B2B/multi-tenant apps and supports automatic first organization creation, default member roles, and a creator role.
+- Auth0 treats organization behavior as a core planning decision and asks whether users log in with an organization context and whether users can be shared across organizations.
+- Kinde supports users in multiple organizations with different roles and permissions and allows default-org auto-assignment to be turned off.
+- WorkOS/AuthKit models directory provisioning per organization, with SCIM-created users producing organization memberships.
+- Frontegg presents SSO and SCIM as tenant/customer self-service configuration.
+
+DarkAuth should therefore treat the organization as the tenant boundary, the session organization as the active account context, and role assignment as org-scoped.
+
+References:
+
+- Clerk organization configuration: https://clerk.com/docs/guides/organizations/configure
+- Clerk create/manage organizations: https://clerk.com/docs/guides/organizations/create-and-manage
+- Clerk Directory Sync: https://clerk.com/docs/guides/configure/auth-strategies/enterprise-connections/directory-sync
+- Auth0 organization planning: https://dev.auth0.com/docs/manage-users/organizations/organizations-overview
+- Auth0 inbound SCIM: https://auth0.com/docs/authenticate/protocols/scim/configure-inbound-scim
+- WorkOS/AuthKit directory provisioning: https://workos.com/docs/authkit/directory-provisioning
+- Frontegg SSO and SCIM: https://frontegg.com/product/sso-scim
+- Kinde organizations: https://docs.kinde.com/build/organizations/orgs-for-developers/
+
+## Current Problems
+
+## Shared Default Organization
+
+New self-registered users and admin-created users are currently placed into the organization with slug `default` when it exists. This makes unrelated users members of the same tenant and gives `Default` product semantics it should not have.
+
+## Hardcoded Role Keys
+
+The registration and organization creation paths look up role keys such as `member` and `org_admin`. This makes the product brittle because instance admins can rename, delete, or replace these roles.
+
+## User UI Is Incomplete
+
+The user UI can create organizations and switch between them, but it does not yet provide full organization management. Users need a first-party way to manage organization members, assign allowed roles, leave organizations, and delete organizations when permitted.
+
+## SCIM Is Instance-Global
+
+SCIM bearer tokens are currently managed as instance-level credentials. In a tenant model, SCIM is almost always configured for a customer organization or an enterprise connection tied to a customer organization. An instance-global SCIM credential makes it too easy for one external IdP integration to affect unrelated organizations.
+
+## Federation Is Instance-Global
+
+Federation connections are currently managed as instance-level OIDC providers with global domain routing. This is also the wrong ownership boundary for B2B. An enterprise SSO connection normally belongs to one customer organization. The organization owns its IdP configuration, domains, membership-on-authentication policy, and SCIM pre-provisioning policy.
+
+## Product Rules
+
+## Organizations
+
+- Every regular user must have at least one active organization membership.
+- `Default` is not special. It can exist, be renamed, deleted, or used like any other organization subject to ordinary safety rules.
+- New installs should not create `Default` automatically as a special bootstrap tenant.
+- Existing installs may keep their existing `Default` organization and memberships unchanged.
+- Existing non-default organization memberships must be preserved unchanged.
+- If an existing user is only in `Default`, leave that user in `Default`; migration should not create per-user organizations for them automatically.
+- Organization slugs are unique and generated from a readable random word pattern when the user does not provide a slug.
+- Organization names created during signup should be human-readable.
+
+## Personal Organization Creation
+
+When a user self-registers:
+
+- Create a new organization in the same transaction as the user and OPAQUE record.
+- Name it `{Name}'s Personal` when a usable display name exists.
+- Fall back to `Personal Organization` when there is no usable display name.
+- Generate a unique slug like `green-star-bubble-yhgw84`.
+- Create an active membership for the new user.
+- Assign the configured default member role or roles.
+- Assign the configured organization creator role or roles.
+- Store the new organization as the current session organization.
+
+When an admin creates a user:
+
+- The admin must choose one of two modes.
+- Mode 1: assign the user to one or more existing organizations.
+- Mode 2: create a new personal organization using the same naming and slug rules as self-registration.
+- The UI should prefer explicit organization assignment because admin-created users are often invited into an existing tenant.
+- The API must reject creating a regular user with zero active organization memberships unless the user is created in a suspended or pre-invite state that cannot sign in.
+
+When a SCIM integration creates a user:
+
+- Do not create a personal organization automatically.
+- The SCIM connection is already bound to an organization, so provisioning creates or updates membership in that organization.
+- If the SCIM connection cannot resolve an organization, reject the operation with a configuration error.
+
+## Default Organization Context
+
+DarkAuth already has session organization selection behavior. Keep and strengthen it:
+
+- If the user has one active organization, select it automatically.
+- If the user has multiple active organizations, use the session organization when still active.
+- If the session organization is missing and multiple active organizations remain, show the organization picker.
+- Never mint org-scoped tokens without a resolved active organization.
+- Never merge roles or permissions across organizations.
+- Users can set their active/default organization in the user UI.
+
+## Roles
+
+Roles remain instance-level templates assigned to organization memberships.
+
+Add role-level configuration:
+
+- `system`: role is instance-managed and protected from ordinary deletion.
+- `assignable`: organization admins may assign this role to members through the user UI and user API.
+- `default_member`: role is automatically assigned to users who join an organization as normal members.
+- `default_creator`: role is automatically assigned to users who create an organization or receive a new personal organization.
+
+Rules:
+
+- There must always be at least one `default_member` role.
+- There must always be at least one `default_creator` role.
+- A role marked `default_member` or `default_creator` cannot be deleted until another role carries the same default flag.
+- A role marked `default_member` or `default_creator` can be system or custom.
+- A system role is managed by instance admins. Organization admins cannot edit role definitions.
+- An organization admin can assign only roles with `assignable = true`.
+- Automatic assignment may use default roles even if they are not manually assignable.
+- Instance admins can assign any role from the admin portal.
+- User-created organizations receive both default member roles and default creator roles for the creator.
+
+This mirrors a common split in auth products: platform-managed roles can exist for safe defaults, while customer-facing role assignment is limited to roles explicitly exposed to organization admins. The exact field names are DarkAuth-specific, but the distinction is standard: instance admins define the role catalog, and tenant/org admins assign the subset they are allowed to use.
+
+This replaces hardcoded role key assumptions. The default seed should still create useful roles such as `member` and `org_admin`, but behavior must depend on flags, not names.
+
+## Permissions
+
+Permissions remain instance-level and admin-controlled.
+
+Rules:
+
+- Regular users cannot create, edit, or delete permissions.
+- Organization admins cannot create, edit, or delete permissions.
+- Permissions are granted to users only through role assignments in the selected organization context.
+- Token claims include only the roles and permissions for the selected organization.
+
+## User Organization Management
+
+The user UI should gain organization management for users with appropriate organization permissions.
+
+Capabilities:
+
+- Create an organization.
+- View organizations the user belongs to.
+- Switch active/default organization.
+- View members of an organization when permitted.
+- Invite or add members when permitted.
+- Assign and remove assignable roles when permitted.
+- Leave an organization.
+- Delete an organization when permitted.
+
+Safety rules:
+
+- A user cannot leave an organization if it is their only active organization.
+- A user cannot remove themselves if doing so would leave the organization without a member who has organization-management authority.
+- A user cannot remove the last active member with a creator/admin-manage role from an organization.
+- A user cannot delete an organization unless they have organization-management authority and deletion is explicitly allowed by the API.
+- Organization deletion must require confirmation and should be audited.
+- If deleting an organization would leave any member with no active organizations, the API must reject deletion unless those users are also being moved or suspended by an instance admin workflow.
+
+Organization-management authority means effective permissions in that organization include the DarkAuth organization management permission, currently `darkauth.org:manage`. Checks must use effective permissions, not a hardcoded role key.
+
+## Organization-Owned Enterprise Connections
+
+SCIM and Federation should be treated as organization-owned enterprise connections.
+
+Provider convention:
+
+- Auth0 creates enterprise connections at tenant level but enables them per organization. Organization connection configuration includes membership-on-authentication behavior.
+- WorkOS describes an organization as the entity whose users sign in with SSO or sync with Directory Sync. Its Admin Portal lets customer IT contacts configure Domain Verification, SSO, and Directory Sync for that organization.
+- Clerk enterprise connections and Directory Sync are tied to organization membership and role mapping.
+- Frontegg exposes SSO and SCIM through tenant/customer self-service.
+
+DarkAuth should use the same mental model:
+
+- Instance admins define platform-wide safety defaults and can see every integration.
+- Organization admins manage enterprise connections for organizations they control.
+- Enterprise SSO connections belong to one organization.
+- SCIM provisioning connections belong to one organization.
+- Domain routing is scoped to organization-owned, verified domains.
+- A user can create an organization, then attach that organization to their existing IdP without asking the instance admin, when they have the required organization permission.
+
+This is intentionally "organization-owned" rather than "account-level". In DarkAuth the organization is the tenant/account boundary. A UI may use the word account if that is clearer for end users, but the data model and authorization checks should use organization ownership.
+
+SSO and SCIM should be grouped in the product as Enterprise Connections:
+
+- An Enterprise Connections area for organization admins.
+- Federation as the SSO connection type.
+- SCIM as the directory provisioning connection type.
+- Shared organization domain verification.
+- Shared audit, setup, status, and health surfaces.
+- Separate connection detail screens where the protocol-specific fields live.
+- Separate backend models and lifecycles for SSO and SCIM. They should feel grouped in the product, but the implementation must not merge OIDC login state with directory provisioning state.
+
+This implies two UI layers:
+
+- Admin portal:
+ - global Federation overview
+ - global SCIM overview
+ - organization detail tabs for Members, Roles, Enterprise Connections, Security, Audit
+ - full override and recovery tools for instance admins
+- User UI:
+ - organization detail pages for organization admins
+ - Members tab
+ - Roles tab limited to assignable roles
+ - Enterprise Connections tab or area
+ - SSO setup under Enterprise Connections
+ - SCIM setup under Enterprise Connections
+ - Security tab for org policies such as Force OTP
+
+## Federation Refactor
+
+Federation should move from global email-domain routing to organization-owned enterprise SSO.
+
+Current implementation:
+
+- `federation_connections` has no `organization_id`.
+- Domains are stored directly on the connection.
+- `/federation/route?email=...` searches all enabled connections and returns the first matching domain.
+- `/federation/start` accepts `connection_id` or email.
+- OIDC callback resolves or creates a local user, then creates a generic user session.
+- If a federated login creates a new user, it currently goes through generic user creation, which may also apply default organization behavior.
+
+Target model:
+
+- A federation connection belongs to exactly one organization.
+- A federation connection may have one or more domains.
+- Domains must be verified before they can be used for automatic email-domain routing.
+- Domain routing by email should return a connection only when exactly one enabled, verified organization connection matches.
+- If legacy data or an operational fault leaves multiple enabled verified connections for the same domain, DarkAuth should refuse automatic routing and require instance-admin repair.
+- Explicit `organization_id` during login should restrict federation routing to that organization.
+- Explicit `connection_id` should be accepted only when the connection is enabled and the selected organization is valid.
+- Federation-created users are global users, but their membership is created or validated in the connection's organization.
+- Federation JIT provisioning must not create a personal organization.
+- If JIT provisioning is enabled, successful login may create the user and active membership in the connection organization.
+- If SCIM pre-provisioning is required, successful login must require an existing active membership created by SCIM or admin action.
+- If membership-on-authentication is enabled, successful login may add membership to the connection organization.
+- If membership-on-authentication is disabled, successful login requires existing active membership.
+- Account linking should remain per connection plus external subject. Because the connection is organization-scoped, this naturally scopes linked identity to the organization integration.
+- A user may have federation identities for multiple organization connections.
+- Token issuance remains selected-organization scoped.
+
+Federation policy should move out of opaque metadata into explicit fields where possible:
+
+- `jit_provisioning`
+- `membership_on_authentication`
+- `require_scim_pre_provisioning`
+- `account_linking_policy`
+- `require_password_for_zk`
+- `allow_passkey_prf`
+- `allow_trusted_device_approval`
+- `allow_non_zk_key_setup_bypass`
+
+Domain verification:
+
+- Organization admins may add domains to an organization integration.
+- Each domain starts unverified.
+- Verification should use DNS TXT in the first implementation.
+- The UI must show the exact TXT record name and value required for verification.
+- The UI must explain that routing will not activate until verification succeeds.
+- Pending domain claims do not reserve the domain.
+- Another organization may claim and verify the same domain while an earlier claim remains pending.
+- Verification must fail if another enabled organization connection already has that domain verified.
+- The database should enforce at most one enabled verified owner for a domain, while allowing multiple pending claims.
+- Domain verification should be re-checkable and auditable.
+- Email domain routing uses only verified domains.
+- Domains are a routing control, not the only security control. The callback must still validate issuer, nonce, signature, audience, email verification policy, and organization policy.
+
+Federation UX:
+
+- Admin portal global Federation page lists all connections with organization name, slug, domains, status, and last login.
+- Admin organization detail Enterprise Connections area manages SSO connections for that organization.
+- User organization Enterprise Connections area lets organization admins configure an OIDC connection, discovery URL, client credentials, domains, claim mapping, and policy.
+- User setup flow should show IdP callback URLs, domain verification instructions, current verification status, and a retry verification action.
+- SAML can remain out of scope unless separately implemented; the refactor should leave a protocol field so SAML can fit later.
+
+## SCIM Refactor
+
+SCIM should move from instance-global token management to organization-scoped provisioning connections.
+
+Provider convention:
+
+- Clerk configures Directory Sync per enterprise connection, and role mapping requires that the enterprise connection is linked to an organization.
+- Clerk deactivates a directory-synced user and revokes active sessions when the IdP removes or deactivates the user.
+- WorkOS/AuthKit requires a directory provisioning integration for every organization that wants to source users and memberships through directory provisioning.
+- WorkOS/AuthKit treats users with verified organization domains as directory-managed, but deprovisioning deactivates the organization membership and revokes sessions rather than automatically deleting the global user.
+- Auth0 inbound SCIM is connection-specific; its organization APIs include provisioning configuration and SCIM token surfaces, and SCIM-provisioned users join organizations through connection auto-membership.
+- Auth0 supports both deactivation through the SCIM `active` attribute and user deletion when the SCIM token has a delete scope.
+- Frontegg presents SSO and SCIM as tenant self-service configuration.
+
+DarkAuth target model:
+
+- Add `scim_connections` and treat bearer tokens as credentials for a SCIM connection.
+- A SCIM connection belongs to exactly one organization.
+- A SCIM bearer token belongs to exactly one SCIM connection and therefore one organization.
+- SCIM endpoints resolve organization context from the bearer token, not from request payload.
+- SCIM-created users are global users with organization memberships in the connection's organization.
+- SCIM deactivation suspends or removes membership in that organization and revokes sessions for that organization context by default.
+- SCIM must not remove a user from unrelated organizations.
+- SCIM group-to-role mappings are configured per organization connection.
+- Group mappings can assign only existing roles selected by instance admins or organization admins with a future permission.
+- If role mapping is disabled, SCIM-created members receive the configured default member role or roles for that organization.
+- SCIM tokens should be created from the organization detail screen in the admin UI and from the user UI for organization admins with a dedicated permission.
+- The existing global SCIM Tokens page can remain as an instance-admin overview of all SCIM connections, but creation should require choosing an organization.
+
+Current implementation changes needed:
+
+- `scim_bearer_tokens` has no `organization_id` or `connection_id`.
+- `scim_users` stores SCIM provisioning state globally by `user_sub`.
+- `scim_groups` stores groups globally.
+- `scim_group_members` stores group membership globally.
+- SCIM group mapping is currently driven by global settings.
+- SCIM deactivation currently revokes global sessions and can delete the whole user.
+
+Target SCIM resource model:
+
+- Local `users` remain global identities.
+- SCIM provisioning state is scoped by connection.
+- SCIM `Users` are identified by connection-specific external IDs and user names.
+- SCIM `Groups` are identified by connection-specific external IDs and display names.
+- SCIM group memberships are scoped to the connection.
+- A local user can be managed by multiple SCIM connections through separate provisioning records.
+- Deprovisioning from one SCIM connection must not delete the global user or affect unrelated organizations.
+- Deprovisioning should suspend or remove the organization membership created by that connection by default.
+- Sessions, pending auth, and auth codes should be revoked when they are bound to the affected organization. If session state is not granular enough yet, conservative full user session revocation is acceptable during the transition, but the target behavior is organization-specific revocation.
+
+SCIM deprovisioning policy:
+
+- Default action: `suspend_membership`.
+- Supported actions should include `suspend_membership`, `remove_membership`, and `delete_user`.
+- `delete_user` is a destructive organization/domain policy and must be opt-in.
+- `delete_user` is allowed only when the user's primary email domain is verified by the organization and the user was provisioned through that organization's SCIM connection.
+- `delete_user` must not delete a global user who has active memberships in unrelated organizations unless a future explicit instance-admin override handles the migration or suspension of those memberships.
+- If `delete_user` is configured but safety checks fail, the operation should fall back to `suspend_membership` or fail closed according to a connection-level setting.
+- The UI must explain the difference between deactivating the member in this organization and deleting the root DarkAuth user account.
+- Root user deletion should require a clear confirmation in admin-owned surfaces and should be auditable.
+- Deleting the root user must revoke sessions, credentials, refresh tokens, pending auth, and linked identities according to the existing user deletion semantics.
+- Directory deletion is not the same as user deprovisioning. Removing a SCIM connection should not automatically deactivate every member, because customers may be switching directory providers.
+
+Security rules:
+
+- A SCIM token must never be able to provision into multiple organizations.
+- SCIM token list responses show only token prefixes, status, organization, created time, last used time, and expiry.
+- SCIM bearer token values are shown exactly once.
+- SCIM provisioning, deprovisioning, group mapping changes, and token lifecycle events must be audited with organization context.
+- SCIM group sync may mutate only memberships and role assignments owned by that SCIM connection or mapping.
+- SCIM group sync must not suspend arbitrary organization members or remove roles assigned manually by admins or by another SCIM connection.
+
+## Data Model
+
+Add or update fields:
+
+- `roles.assignable boolean not null default false`
+- `roles.default_member boolean not null default false`
+- `roles.default_creator boolean not null default false`
+- `federation_connections.organization_id uuid not null references organizations(id)`
+- `federation_connections.protocol text not null default 'oidc'`
+- `federation_connections.jit_provisioning boolean not null default true`
+- `federation_connections.membership_on_authentication boolean not null default true`
+- `federation_connections.require_scim_pre_provisioning boolean not null default false`
+- `federation_connection_domains.id uuid primary key`
+- `federation_connection_domains.connection_id uuid not null references federation_connections(id)`
+- `federation_connection_domains.organization_id uuid not null references organizations(id)`
+- `federation_connection_domains.domain text not null`
+- `federation_connection_domains.verification_status text not null default 'pending'`
+- `federation_connection_domains.verification_token_hash text`
+- `federation_connection_domains.verified_at timestamp`
+- `federation_connection_domains.last_checked_at timestamp`
+- `federation_connection_domains.enabled boolean not null default true`
+- `scim_connections.id uuid primary key`
+- `scim_connections.organization_id uuid not null references organizations(id)`
+- `scim_connections.name text not null`
+- `scim_connections.enabled boolean not null default true`
+- `scim_connections.deprovision_action text not null default 'suspend_membership'`
+- `scim_connections.delete_user_safety text not null default 'fail_closed'`
+- `scim_bearer_tokens.connection_id uuid not null references scim_connections(id)`
+- `scim_bearer_tokens.organization_id uuid not null references organizations(id)`
+- `scim_bearer_tokens.scopes text[]`
+- `scim_connection_users.id uuid primary key`
+- `scim_connection_users.connection_id uuid not null references scim_connections(id)`
+- `scim_connection_users.organization_id uuid not null references organizations(id)`
+- `scim_connection_users.user_sub text not null references users(sub)`
+- `scim_connection_users.external_id text`
+- `scim_connection_users.user_name text not null`
+- `scim_connection_users.primary_email text`
+- `scim_connection_users.domain_managed boolean not null default false`
+- `scim_connection_users.active boolean not null default true`
+- `scim_connection_groups.id uuid primary key`
+- `scim_connection_groups.connection_id uuid not null references scim_connections(id)`
+- `scim_connection_groups.organization_id uuid not null references organizations(id)`
+- `scim_connection_groups.external_id text`
+- `scim_connection_groups.display_name text not null`
+- `scim_connection_group_members.group_id uuid not null references scim_connection_groups(id)`
+- `scim_connection_group_members.connection_user_id uuid not null references scim_connection_users(id)`
+- `scim_group_role_mappings` normalized table:
+ - `id uuid primary key`
+ - `connection_id uuid not null`
+ - `organization_id uuid not null`
+ - `scim_group_id text`
+ - `scim_external_id text`
+ - `scim_display_name text`
+ - `role_id uuid not null`
+ - `precedence integer not null default 0`
+- `audit_logs.organization_id uuid references organizations(id)`
+- `audit_logs.enterprise_connection_id uuid`
+- `audit_logs.enterprise_connection_type text`
+- Optional `users.last_selected_organization_id uuid references organizations(id)` if selected organization should survive all sessions.
+
+Keep:
+
+- `organizations.created_by_user_sub`
+- `organization_members`
+- `organization_member_roles`
+- `role_permissions`
+- `sessions.data.organizationId`
+- `sessions.data.organizationSlug`
+
+Remove or stop using:
+
+- Runtime assumptions that `organizations.slug = 'default'` exists.
+- Runtime assumptions that role key `member` exists.
+- Runtime assumptions that role key `org_admin` exists.
+- Instance-global SCIM token behavior.
+- Instance-global federation connection behavior.
+- Global unverified email-domain routing.
+
+Indexes and constraints:
+
+- Federation domains should allow duplicate pending claims.
+- Federation domains should prevent more than one enabled verified owner for the same normalized domain.
+- SCIM connection users should be unique by `(connection_id, external_id)` when `external_id` is present.
+- SCIM connection users should be unique by `(connection_id, user_name)`.
+- SCIM connection groups should be unique by `(connection_id, external_id)` when `external_id` is present.
+- SCIM connection groups should be unique by `(connection_id, display_name)`.
+
+## Migration Strategy
+
+This project is still pre-launch enough to favor a clean behavioral cutover, but existing data should not be scrambled.
+
+Migration rules:
+
+- Do not delete existing organizations.
+- Do not delete the existing `Default` organization.
+- Do not move users out of `Default`.
+- Do not create personal organizations for existing users automatically.
+- Add role flags.
+- Mark the existing `member` role as `default_member` if present.
+- Mark the existing `org_admin` role as `default_creator` if present.
+- If either role is missing, create an equivalent system role with the correct default flag.
+- Ensure at least one role has `default_member = true`.
+- Ensure at least one role has `default_creator = true`.
+- Mark reasonable default roles as `assignable = true`.
+- Backfill existing SCIM tokens only if a safe organization can be chosen. Otherwise mark them disabled and require admin reconfiguration.
+- Backfill existing federation connections only if a safe organization can be chosen. Otherwise disable them and require admin reconfiguration.
+- Existing federation domains should start unverified unless verification evidence exists.
+
+Fresh install rules:
+
+- Seed base permissions.
+- Seed `member` as `default_member = true`.
+- Seed `org_admin` as `default_creator = true`.
+- Seed both roles as `assignable = true` unless there is a strong reason to hide them from organization admins.
+- Do not seed a special `Default` organization.
+- The bootstrap admin remains an admin user, not a regular user organization owner.
+
+## API Changes
+
+Admin API:
+
+- Role create/update supports `assignable`, `default_member`, and `default_creator`.
+- Role delete rejects deletion if it would remove the last default member or creator role.
+- User create accepts either existing organization memberships or `createPersonalOrganization`.
+- Organization member role assignment can assign any role for instance admins.
+- Organization detail includes federation connection CRUD for that organization.
+- Organization detail includes SCIM connection and token CRUD for that organization.
+- Global federation list includes organization name and slug.
+- Global SCIM token list includes organization name and slug.
+- SCIM token create requires `organizationId` or a parent organization route.
+- Federation connection create requires `organizationId` or a parent organization route.
+
+User API:
+
+- Organization create assigns default member and default creator roles.
+- Organization member role assignment allows only `assignable = true` roles.
+- Add organization member invite/add endpoints if missing or incomplete.
+- Add organization leave endpoint.
+- Add organization delete endpoint for permitted organization admins.
+- Add endpoint to list assignable roles for an organization.
+- Add organization federation connection endpoints for permitted organization admins.
+- Add organization SCIM connection and token endpoints for permitted organization admins.
+- Session organization update remains and continues validating active membership.
+
+Federation API:
+
+- Email route lookup considers only enabled, verified, organization-scoped connections.
+- Start endpoint supports explicit `organization_id`.
+- Callback creates or validates membership in the connection organization according to policy.
+- JIT-created federated users do not receive a personal organization.
+
+SCIM API:
+
+- Bearer token authentication resolves a single SCIM connection and organization.
+- User create/update/deactivate applies only within token connection and organization context.
+- Group create/update/delete applies only within token connection and organization context.
+- Group-to-role mapping is per organization.
+
+## UI Changes
+
+Admin UI:
+
+- Role create/edit includes toggles for:
+ - System role
+ - Assignable by organization admins
+ - Default member role
+ - Default organization creator role
+- Role delete displays a clear error when a role is protected by default flags.
+- User create includes organization assignment:
+ - Assign to existing organization.
+ - Create personal organization.
+- Organization detail gains tabs:
+ - Details
+ - Members
+ - Roles
+ - Enterprise Connections
+ - Security
+ - Audit
+- Organization detail Enterprise Connections area groups SSO and SCIM for that organization.
+- SSO connection detail manages federation settings for that organization.
+- SCIM connection detail manages provisioning settings and bearer tokens for that organization.
+- Federation page becomes a global SSO overview and requires organization selection when creating connections.
+- SCIM Tokens page becomes a global provisioning overview and requires organization selection when creating tokens.
+
+User UI:
+
+- Profile or Organizations area lists all active organizations.
+- Organization detail page shows members and roles.
+- Organization admins can add/invite users.
+- Organization admins can assign roles marked `assignable`.
+- Organization admins can open Enterprise Connections for their organization.
+- Organization admins can configure SSO for their organization.
+- Organization admins can configure SCIM for their organization.
+- Organization admins can copy setup values, callback URLs, ACS URLs when SAML exists, SCIM base URL, and one-time SCIM bearer tokens.
+- Organization admins can see DNS TXT verification instructions and retry domain verification.
+- Users can leave an organization when safety rules allow.
+- Organization admins can delete an organization when safety rules allow.
+- Organization creation uses `{Name}'s Personal` style naming for signup and explicit user-entered names for manual creation.
+
+## Slug Generation
+
+Generated personal organization slugs should be readable and unique.
+
+Pattern:
+
+`{adjective}-{noun}-{object}-{suffix}`
+
+Examples:
+
+- `green-star-bubble-yhgw84`
+- `quiet-river-maple-kx72qd`
+- `silver-cloud-lantern-pm4z9a`
+
+Rules:
+
+- Use lowercase ASCII.
+- Use hyphen separators.
+- Use a short random suffix.
+- Retry on collision.
+- Keep generated slugs separate from display names.
+- Users may rename organizations and slugs later if the UI supports it.
+
+## Security Requirements
+
+- Never mint an org-scoped token without an active organization.
+- Never grant permissions from non-selected organizations.
+- Never let user APIs assign non-assignable roles.
+- Never let users create or modify roles or permissions.
+- Never let a user leave their last active organization.
+- Never let an organization lose its last organization-management-capable member through user-side actions.
+- Never let SCIM tokens affect more than one organization.
+- Never let federation connections authenticate a user into the wrong organization.
+- Never route by unverified domains.
+- Never treat domain matching as sufficient authorization; membership and connection policy are still required.
+- Never let SCIM group sync alter memberships or role grants that were not created by that SCIM connection.
+- Never let federation or SCIM JIT create a root user without also creating or validating the intended organization membership in the same flow.
+- Never rely on `roles.system` to decide whether an organization admin can assign a role.
+- Never accept a federation callback whose state is not bound to the intended connection, organization, and client context.
+- Audit all organization creation, deletion, membership changes, role changes, SCIM token lifecycle events, and SCIM provisioning changes.
+- Audit all federation connection changes, domain verification changes, federation login starts, callbacks, account links, and membership-on-authentication events with organization context.
+- Avoid organization existence leaks for unauthorized callers.
+
+## Mandatory Fixes From Codebase Verification
+
+These are not optional follow-up notes. They are required fixes in the organization refactor because they are current correctness, isolation, or security risks.
+
+## Fix Root User Creation Coupling
+
+Current generic user creation also performs organization placement. Admin creation, SCIM provisioning, and federation JIT all depend on this behavior today.
+
+Required outcome:
+
+- Root user creation is separated from organization provisioning.
+- Self-registration creates a personal organization and selected session organization.
+- Admin user creation must choose existing organization memberships or personal organization creation.
+- Federation JIT must create or validate membership in the federation connection's organization.
+- SCIM provisioning must create or update membership in the SCIM connection's organization.
+- No path can create an interactive regular user with zero active organizations unless that user is explicitly suspended or pre-invite and cannot sign in.
+
+## Fix SCIM Ownership Boundaries
+
+Current SCIM group sync can remove roles or suspend members across an entire mapped organization, including manually managed members.
+
+Required outcome:
+
+- SCIM connections track which memberships they created or manage.
+- SCIM group mappings track which role grants they created or manage.
+- SCIM group sync can remove or suspend only SCIM-owned memberships and SCIM-owned role grants.
+- Manual admin assignments and assignments from other SCIM connections are preserved.
+- SCIM deprovisioning defaults to membership suspension/removal in the connection organization.
+- Root user deletion is allowed only through explicit verified-domain policy and safety checks.
+
+## Fix Federation Routing And State Binding
+
+Current federation domain routing is global, unverified, first-match routing. OIDC state does not bind organization or client context.
+
+Required outcome:
+
+- Federation connections belong to exactly one organization.
+- Email-domain routing uses only enabled, verified domains.
+- Pending domain claims do not reserve a domain.
+- Verified domain uniqueness is enforced for enabled connections.
+- Federation start state binds connection, organization, client, nonce, PKCE verifier, and return URL.
+- Federation callback rejects mismatched connection, organization, or client context.
+- Domain matching remains routing only; callback still requires valid membership and connection policy.
+
+## Fix Role Assignability
+
+Current user-side assignability is overloaded onto `roles.system`.
+
+Required outcome:
+
+- `system` means instance-managed/protected.
+- `assignable` means organization admins may assign the role.
+- User-side role catalogs and assignment endpoints use `assignable`.
+- Instance admins can still assign any role.
+- Default role protection uses `default_member` and `default_creator`, not `system`.
+
+## Fix Org Safety Guards
+
+Current admin and user organization mutation paths do not fully enforce last-organization and last-manager safety.
+
+Required outcome:
+
+- User-side flows cannot leave a user with zero active organizations.
+- User-side flows cannot leave an organization without a management-capable active member.
+- Admin flows either enforce the same safety checks or require an explicit recovery/override path with audit logging.
+- Organization deletion rejects when it would orphan regular users unless an admin workflow moves or suspends them.
+
+## Fix Enforced Federation Policy
+
+Current federation policy-like UI settings are stored in metadata and are not enforced by runtime.
+
+Required outcome:
+
+- Enforced federation policy moves to explicit model fields or a clearly validated policy object read by runtime.
+- `account_linking_policy`, `jit_provisioning`, `membership_on_authentication`, and `require_scim_pre_provisioning` are separate runtime decisions.
+- Existing metadata-only policy controls are removed, migrated, or clearly treated as display-only until runtime support exists.
+
+## Acceptance Criteria
+
+- Fresh install does not create a special `Default` organization.
+- Existing `Default` organizations remain ordinary organizations after migration.
+- Self-registration creates a personal organization and selects it for the session.
+- Admin user creation requires organization assignment or personal organization creation.
+- SCIM tokens are tied to one organization.
+- Federation connections are tied to one organization.
+- Federation domain routing uses only verified organization domains.
+- Pending domain claims do not block another organization from verifying the domain.
+- A verified domain cannot be active for two enabled organization connections at the same time.
+- Federated JIT users are added to the connection organization, not a new personal organization.
+- Federation callback state is bound to the selected organization and connection.
+- User-created organizations grant both default member and default creator roles to the creator.
+- Organization admins can assign only assignable roles.
+- Instance admins can assign any role.
+- Users cannot leave their only active organization.
+- Organizations cannot lose their last management-capable member through user UI actions.
+- SCIM deprovisioning defaults to organization membership suspension/removal, not global user deletion.
+- SCIM root user deletion is possible only through explicit verified-domain policy and safety checks.
+- SCIM group sync cannot modify manually assigned memberships or roles.
+- Federation start and callback are bound to connection, organization, and client context.
+- Federation and SCIM JIT cannot create sign-in-capable users without active organization membership.
+- `roles.system` is no longer used as the organization-admin assignability gate.
+- Enforced federation policies are read by backend runtime, not only stored in UI metadata.
+- Token claims remain scoped to the selected organization.
+- Existing non-default memberships remain unchanged.
+- `npm run tidy` and `npm run build` pass after implementation.
+
+## Codebase Verification Notes
+
+These notes come from a read-only codebase pass over API models/controllers, admin UI, user UI, SCIM, and federation. They are here so implementation agents do not have to rediscover the same coupling.
+
+## Existing Default And Role Coupling
+
+- Self-registration currently creates the root user, then joins `organizations.slug = 'default'`, assigns `roles.key = 'member'`, and creates a session without organization context in `packages/api/src/models/registration.ts`.
+- Admin-created users use `createUser` in `packages/api/src/models/users.ts`, which has the same implicit `default` and `member` behavior.
+- Federation JIT and SCIM provisioning both call generic user creation paths, so they inherit the same default-org behavior.
+- User-created organizations currently assign only the hardcoded `org_admin` role in `packages/api/src/models/organizations.ts`.
+- Install/bootstrap and earlier RBAC migrations create `Default`, backfill all users into it, and assign `member`.
+- Implementation should split root user creation from organization provisioning. Registration, admin creation, federation JIT, and SCIM provisioning need separate provisioning modes rather than one shared `createUser` side effect.
+- Removing special `Default` before federation and SCIM provisioning are org-scoped can create root users with no active memberships. Do this as an ordered refactor, not a one-file removal.
+
+## Current Role Semantics
+
+- The `roles` table currently has `system` only.
+- User-side assignability currently means `roles.system = true`.
+- The refactor must make `system` and `assignable` independent. `system` means instance-managed/protected. `assignable` means organization admins may grant it.
+- Role deletion currently protects system roles only. Default role flags need their own delete/update protection.
+- Admin member role assignment can continue to see all roles, but user-side role catalogs must list only assignable roles.
+
+## Existing Organization APIs
+
+- User API already has organization list, create, detail, member list, invite, and role add/remove endpoints.
+- User API does not yet have leave organization, remove member, delete organization, assignable role catalog, or Enterprise Connections endpoints.
+- Existing user-side role assignment has no last-manager guard and no assignable flag.
+- Admin member add creates an organization membership with no roles unless roles are assigned after creation. Decide during implementation whether admin add-member should auto-assign default member roles or stay explicit.
+- Admin organization and member deletion currently have no last-active-organization or last-management-capable-member guard.
+
+## Session And Token Context
+
+- Organization selection logic already handles explicit org, one-org fallback, multiple-org ambiguity, stale session org, and zero active org.
+- Refresh-token exchange can still fail with `ORG_CONTEXT_REQUIRED` when a multi-org user has no usable session organization. The new registration and login paths should set the selected organization as early as possible.
+- Sessions store organization context in `sessions.data.organizationId` and `sessions.data.organizationSlug`.
+- `auth_codes.organization_id` and `pending_auth.organization_id` already exist, so org-scoped SCIM deprovisioning can target those records before falling back to full user revocation.
+- Immediate post-login user UI state should include selected organization from login responses or a `/session` refresh, otherwise the first navigation can show stale or missing organization context.
+
+## Federation Implementation Notes
+
+- `federation_connections` is global today: no `organization_id`, and domains are stored directly in a text array.
+- Domain routing scans all enabled connections and returns the first domain match. There is no verification, uniqueness, ambiguity handling, or org context.
+- OIDC state is bound to connection, nonce, PKCE verifier, and return URL, but not organization or client context.
+- Login UI sends `client_id` for federation start, but current federation start handling does not bind it into state.
+- Callback auto-selects organization only when the user has exactly one active membership. Multiple memberships leave session org unset; zero memberships reject login.
+- Federation JIT currently uses generic `createUser`; without `Default`, it can create a linked but unusable user.
+- `account_linking_policy = email_verified` currently means both link existing users and create new users. Split account linking from `jit_provisioning` and `membership_on_authentication`.
+- Existing policy-like federation fields live in admin UI metadata and are not enforced by backend runtime. Move enforced policy into columns or clearly treat metadata as display-only during transition.
+- The existing enum value `email` for account linking is present in schema but rejected by model/controller validation. Migration should either remove it or deliberately define compatibility behavior.
+
+## SCIM Implementation Notes
+
+- SCIM bearer tokens are global today and identify only the token, not an organization or connection.
+- `scim_users`, `scim_groups`, and `scim_group_members` are global and have global uniqueness constraints.
+- SCIM user creation currently calls generic local user creation, so users can be added to `Default` before any SCIM group mapping runs.
+- SCIM deprovisioning currently marks a global `scim_users` row inactive, deletes all sessions, auth codes, and pending auth for the user, and can delete the root user through global setting `users.scim.deprovision_action`.
+- SCIM sign-in/key policy is global per user. It cannot distinguish active in one organization from deprovisioned in another.
+- SCIM role mapping is stored in global settings and uses role keys. It must move to organization/connection-owned rows using role IDs.
+- Current SCIM group sync can remove mapped roles and suspend members across an entire mapped organization, including admin-created or otherwise unrelated members. The refactor must track membership and role-grant provenance so SCIM only changes what the connection owns.
+- Existing `users.scim.*` settings need connection-scoped replacements or explicit migration behavior: sign-in policy, key unlock policy, deprovision action, unknown group policy, and mappings.
+- Existing global SCIM users should be attached to one backfilled connection only when safe. Ambiguous rows should be disabled from SCIM management until admin reconfiguration.
+
+## UI Implementation Notes
+
+- Admin organization detail is already a single page with Details, Security, Members, and role editing. Tabs can be added inside the existing `/organizations/:organizationId` route.
+- Keep global admin Federation and SCIM pages as instance-admin overviews. Add org-scoped admin routes under organization detail for Enterprise Connections.
+- Admin user creation currently sends only email, name, and sub. Replace that implicit default behavior with a required union: existing organization memberships or personal organization creation.
+- User UI already supports organization list/create/switch in Profile, has a standalone `/switch-org` flow, and has authorize-time organization selection. Preserve those flows while adding a dedicated organization detail surface.
+- User UI API client currently wraps only org list/create/session-switch. It needs wrappers for existing org detail/member/invite/role endpoints, then new wrappers for leave, remove member, delete, assignable roles, SSO, and SCIM.
+- Keep account-level Security as connected identities and sign-in status. Put SSO and SCIM configuration under organization Enterprise Connections for users with `darkauth.org:manage` or a future narrower permission.
+
+## Test Notes
+
+- Registration tests currently do not assert personal organization creation, default role assignment, or session organization.
+- Existing org-selection tests are useful and should be preserved.
+- Some tests use a `Default` organization or `default` slug as fixtures. Those should become ordinary fixtures, not special behavior.
+- Add tests for the ordered migration hazards: federation JIT without `Default`, SCIM provisioning without `Default`, and multi-org refresh-token behavior with missing/stale session org.
+
+## Implementation Handoff Status
+
+The first implementation pass landed a large part of the refactor across API, admin UI, and user UI. A second top-level integration pass (2026-06-01) resolved the remaining backend blockers and completed full-repo tidy and build. The backend behavioral cutover is now complete and verified; the main remaining work is the org-scoped enterprise-connection self-service UI and real DNS TXT domain verification.
+
+Resolved in the 2026-06-01 integration pass:
+
+- Removed special `Default` creation/backfill. `ensureDefaultOrganizationAndSchema` was renamed `ensureOrganizationSchema` in `packages/api/src/models/install.ts`; it keeps the idempotent schema bootstrap but no longer inserts a `default` organization, backfills users into it, or backfills the `member` role onto its members. Existing `Default` rows from migrations/old installs are left untouched.
+- Fixed the API typecheck failure in `packages/api/src/models/registration.ts` by returning the created organization from the registration transaction instead of mutating a closure-captured variable. `npm run typecheck` passes across all workspaces.
+- Added enterprise connection audit context: `audit_logs.enterprise_connection_id` and `audit_logs.enterprise_connection_type` columns (migration `0037_audit_enterprise_connection`), wired through `AuditEvent`/`AuditFilters`/`logAuditEvent`, populated in SCIM resource/token events (type `scim`) and federation connection CRUD events (type `federation`, via a new `extractAuditContext` hook in `withAudit`).
+- Updated `specs/RBAC.md`, confirmed `specs/ORG_SELECTION.md` needed no changes, added a superseded banner to `specs/8_DEFAULT_USER_GROUP.md`, and neutralized the `2_CORE.md` example slug.
+- Ran focused API/model tests (registration, users, organizations, RBAC, SCIM, federation — all passing), then full-repo `npm run tidy` and `npm run build` (both green).
+
+Completed in the 2026-06-01 parallel pass (three package-scoped agents: api / admin-ui / user-ui):
+
+- Real DNS TXT domain verification: `createFederationConnectionDomain` now generates a token, stores only its hash, and returns the exact record to publish (name `_darkauth-verification.`, value `darkauth-domain-verification=`); `runFederationDomainDnsVerification` does a real `dns.resolveTxt` check (resolver injectable for tests), respects the one-enabled-verified-owner constraint, and sets `lastCheckedAt`. Admin domain endpoints added under `/admin/federation/connections/:id/domains[...]`.
+- New user-API org-scoped Enterprise Connection endpoints (`/organizations/:orgId/federation/connections[...]` and `/organizations/:orgId/scim/connections[...]`), all gated by `requireOrganizationManagePermission` and org-scoped to prevent cross-org access; all mutations audited with org + enterprise-connection context.
+- User UI: Enterprise Connections area on org detail with SSO (OIDC) setup, SCIM connection + one-time bearer-token reveal, and DNS TXT instructions + retry verification.
+- Admin UI: protected-role deletion messaging (API now returns codes `LAST_DEFAULT_MEMBER_ROLE` / `LAST_DEFAULT_CREATOR_ROLE` / `SYSTEM_ROLE_PROTECTED`), required org selection for SCIM token and federation-connection creation, org-scoped Enterprise Connections route under org detail, and an admin SSO setup/domain-verification page.
+- Legacy `email` account-linking enum: migration `0038_federation_email_linking_migration` normalizes existing `email` rows to `email_verified`.
+- Tests: token-claim-after-personal-org and multi-org refresh-token (missing/stale session org → `ORG_CONTEXT_REQUIRED`) added; DNS verification test added. Focused API tests, full `npm run typecheck`, and `npm run tidy` all green.
+
+Remaining work for the next agent:
+
+- Run the relevant Playwright end-to-end tests against a running stack (the only unchecked checklist item). The new UI was verified by typecheck/tidy/build only, not exercised live, so the API↔UI contract should be smoke-tested (esp. response envelope/field shapes — the UI wrappers were written tolerant of either bare objects or `{ connection } / { domain } / { token }` envelopes).
+
+## Parallel Implementation Checklist
+
+### Track A: Specs, Data Model, And Migration
+
+- [x] Update `specs/RBAC.md`, `specs/ORG_SELECTION.md`, and docs to remove special `Default` guidance.
+- [x] Add role flags to schema and migrations.
+- [x] Add organization-scoped federation connection fields and domain verification tables.
+- [x] Add organization-scoped SCIM token fields or create SCIM connection tables.
+- [x] Add audit organization and enterprise connection context.
+- [x] Add migration for role default flags and assignable flags.
+- [x] Add migration safety checks for at least one default member role and one default creator role.
+- [x] Split root user creation from organization provisioning before removing default-org behavior.
+- [x] Remove fresh-install creation of special `Default`.
+- [x] Preserve existing `Default` as normal data.
+- [x] Disable or quarantine existing global federation and SCIM integrations when no safe organization can be inferred.
+- [x] Migrate or quarantine existing global SCIM users, groups, mappings, and policy settings.
+
+### Track B: Role And Permission Semantics
+
+- [x] Replace hardcoded `member` role lookup with default member role resolver.
+- [x] Replace hardcoded `org_admin` role lookup with default creator role resolver.
+- [x] Enforce role deletion protection for default roles.
+- [x] Enforce `assignable` for user-side role assignment.
+- [x] Stop using `system` as the user-side assignability gate.
+- [x] Keep instance-admin role assignment unrestricted.
+- [x] Add tests for missing, multiple, and protected default roles.
+
+### Track C: Registration And User Creation
+
+- [x] Create personal organization during self-registration.
+- [x] Assign default member and default creator roles during self-registration.
+- [x] Store new personal organization in the session.
+- [x] Update admin user creation to require organization assignment mode.
+- [x] Add admin create-user support for existing organization assignment.
+- [x] Add admin create-user support for personal organization creation.
+- [x] Keep federation JIT and SCIM provisioning from creating personal organizations.
+- [x] Remove automatic assignment of new users to `Default`.
+
+### Track D: User Organization Management API
+
+- [x] Add endpoint to list assignable roles.
+- [x] Add or complete endpoint to add/invite members.
+- [x] Add endpoint to remove members from an organization.
+- [x] Add endpoint to leave organization.
+- [x] Add endpoint to delete organization.
+- [x] Add safeguards for last active organization.
+- [x] Add safeguards for last management-capable member.
+- [x] Add audit logs for user-side organization actions.
+
+### Track E: User UI Organization Management
+
+- [x] Add organizations list/detail navigation.
+- [x] Preserve existing Profile org list/create/switch and `/switch-org` flows.
+- [x] Add user API client wrappers for existing organization detail, member, invite, and role endpoints.
+- [x] Add member management UI.
+- [x] Add assignable role picker.
+- [x] Add invite/add member UI.
+- [x] Add leave organization action.
+- [x] Add delete organization action.
+- [x] Add clear disabled states for safety-rule failures.
+- [x] Verify multi-org session switching remains clear.
+
+### Track F: SCIM Refactor
+
+- [x] Add SCIM connection model owned by organization.
+- [x] Make SCIM tokens connection-scoped and organization-scoped.
+- [x] Update SCIM auth middleware to resolve connection and organization from token.
+- [x] Scope SCIM users, groups, and group memberships by connection.
+- [x] Ensure SCIM create/update/deactivate touches only one organization.
+- [x] Move SCIM role mappings to organization scope.
+- [x] Track SCIM membership and role-grant provenance.
+- [x] Prevent SCIM group sync from mutating manually managed members or roles.
+- [x] Move global `users.scim.*` policy settings to connection/org scope.
+- [x] Add org-scoped revocation helpers for sessions, auth codes, and pending auth.
+- [x] Add SCIM deprovisioning policy with safe default membership suspension.
+- [x] Add guarded optional root user deletion for verified-domain managed users.
+- [x] Update admin UI to create SCIM tokens from organization context.
+- [x] Add user UI SCIM setup under Enterprise Connections for organization admins.
+- [x] Keep global SCIM page as overview only.
+- [x] Add tests proving SCIM cannot cross organization boundaries.
+- [x] Add tests for SCIM deprovisioning policy, including delete-user safety failures.
+
+### Track G: Federation Refactor
+
+- [x] Make federation connections organization-scoped.
+- [x] Add domain verification model and DNS TXT verification.
+- [x] Allow duplicate pending domain claims but only one enabled verified owner.
+- [x] Add UI instructions for DNS TXT verification and retry status checks.
+- [x] Update domain route lookup to use verified organization domains only.
+- [x] Ensure domain routing is treated only as routing, not authorization.
+- [x] Update federation start to support explicit organization context.
+- [x] Bind federation start state to client and organization context.
+- [x] Update federation callback to create or validate membership in the connection organization.
+- [x] Bind callback state to connection and organization context.
+- [x] Ensure federated JIT user creation does not create a personal organization.
+- [x] Split account linking from JIT provisioning and membership-on-authentication behavior.
+- [x] Move federation policy out of metadata where practical.
+- [x] Decide migration behavior for legacy `email` account-linking enum value.
+- [x] Add admin UI SSO setup under organization Enterprise Connections.
+- [x] Add user UI SSO setup under Enterprise Connections for organization admins.
+- [x] Add tests proving federation cannot authenticate into the wrong organization.
+
+### Track H: Admin UI And Admin API
+
+- [x] Add role flag fields to role create/edit UI.
+- [x] Add role flag fields to role APIs.
+- [x] Add protected-role deletion messaging.
+- [x] Add organization choice to admin user creation UI.
+- [x] Add personal organization creation to admin user creation UI.
+- [x] Add org-scoped admin Enterprise Connections routes under organization detail.
+- [x] Add SCIM token organization selection in admin UI.
+- [x] Add federation connection organization selection in admin UI.
+- [x] Add Enterprise Connections grouping on organization detail.
+- [x] Update admin organization detail to expose provisioning status.
+
+### Track I: Tests And Verification
+
+- [x] Replace default organization membership tests.
+- [x] Add self-registration personal organization tests.
+- [x] Add admin-created user organization assignment tests.
+- [x] Add user leave/delete organization safety tests.
+- [x] Add assignable role enforcement tests.
+- [x] Add token claim tests after personal organization creation.
+- [x] Add multi-org refresh-token tests for missing and stale session organization.
+- [x] Add federation JIT without `Default` tests.
+- [x] Add SCIM provisioning without `Default` tests.
+- [x] Add SCIM org-boundary tests.
+- [x] Add federation org-boundary tests.
+- [x] Run focused API/model tests.
+- [ ] Run relevant Playwright tests.
+- [x] Run `npm run tidy`.
+- [x] Run `npm run build`.
+
+## Open Decisions For Implementation
+
+- Whether personal organization display names should use `{firstName}'s Personal`, `{fullName}'s Personal`, or `{email local part}'s Personal` when name is absent.
+- Whether user-side organization deletion should hard-delete immediately or soft-delete/suspend first.
+- Whether role defaults should allow multiple roles per default flag or exactly one role per flag.
+- Whether admin add-member should auto-assign default member roles or require explicit role selection.
+- Whether organization admins should be allowed to configure SCIM in the user UI in the first implementation, or whether that waits until after admin UI support lands.
+- Whether organization admins should be allowed to configure Federation in the user UI in the first implementation, or whether that waits until after admin UI support lands.
+- Whether federation membership-on-authentication should be enabled by default for new connections.
+- Whether SCIM `delete_user` should be enabled for any org-owner self-service path in the first implementation, or limited to instance admins until the recovery story is proven.
diff --git a/specs/ORG_SWITCHING.md b/specs/ORG_SWITCHING.md
new file mode 100644
index 00000000..f8124d03
--- /dev/null
+++ b/specs/ORG_SWITCHING.md
@@ -0,0 +1,381 @@
+# Organization Switching And SDK Support
+
+## Summary
+
+DarkAuth already supports organization-scoped login. The hosted authorize UI can ask a multi-org user which organization to use, `/authorize` accepts an `organization_id` hint, `/switch-org` lets a signed-in user change their DarkAuth session organization, and `/token` mints ID/access tokens with the selected `org_id`, `org_slug`, roles, and permissions.
+
+The remaining gap is product and SDK shape. A relying-party app such as Atlas wants a Slack-like organization rail inside its own UI: list my organizations, click one, receive a fresh token for that organization, clear app tenant state, and continue without forcing a logout/login loop. DarkAuth should make that flow feel like a normal identity-provider feature, comparable to organization switching in Auth0, Clerk, Frontegg, WorkOS, or similar providers.
+
+This spec defines the DarkAuth API, SDK, and documentation work needed to support that pattern in an industry-standard way.
+
+## Current State
+
+DarkAuth has the important backend primitives:
+
+- `GET /authorize` accepts optional `organization_id`.
+- `POST /authorize/finalize` binds the selected org to the authorization code and session.
+- `POST /token` issues org-scoped ID/access tokens for authorization-code and refresh-token grants.
+- `GET /api/user/organizations` lists active organizations for the current user session.
+- `GET /api/user/session` returns current session organization context.
+- `POST /api/user/session/organization` validates membership and updates `sessions.data.organizationId`.
+- `/switch-org` is a hosted first-party UI that calls the session organization endpoint and validates `return_to`.
+
+The current `@darkauth/client` exposes login/session primitives:
+
+- `setConfig`
+- `initiateLogin`
+- `handleCallback`
+- `getStoredSession`
+- `refreshSession`
+- `logout`
+
+The client does not currently expose typed organization helpers, and `initiateLogin` does not accept an `organizationId` option. Apps can redirect to hosted DarkAuth routes manually, but they cannot yet implement polished app-owned org switchers from the SDK alone.
+
+## Goals
+
+- Let apps render their own organization switcher while DarkAuth remains the source of truth.
+- Let apps request a fresh token for a selected organization without full logout.
+- Keep the active organization represented by normal OAuth/OIDC token claims.
+- Preserve hosted DarkAuth org selection for apps that do not build their own switch UI.
+- Make SDK APIs simple enough for Atlas-style tenant switching.
+- Avoid cross-organization permission merging.
+- Avoid any endpoint that mints a token for an org without server-side membership validation.
+
+## Non-Goals
+
+- Multi-org tokens containing claims for every organization.
+- Letting apps mutate DarkAuth organization membership through the auth SDK.
+- Replacing the hosted `/switch-org` page.
+- Supporting arbitrary third-party cross-origin session mutation without a browser redirect or proper OAuth flow.
+- Bypassing consent, PKCE, client redirect validation, or membership validation.
+
+## Standard Provider Model
+
+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:
+
+1. App asks DarkAuth for active organizations.
+2. User selects an organization inside the app.
+3. App starts a new authorization request with `organization_id=`.
+4. DarkAuth validates the user's active membership.
+5. DarkAuth returns an authorization code to the registered app callback.
+6. App handles the callback and receives a new org-scoped session/token.
+7. App clears tenant-local state and loads data for the selected org.
+
+This is the safest default for third-party relying parties because it stays inside OAuth redirect, PKCE, state, redirect URI, and token issuance rules.
+
+Hosted fallback flow:
+
+1. App redirects to `/switch-org?client_id=&return_to=`.
+2. DarkAuth shows its first-party org switch screen.
+3. DarkAuth updates the first-party session organization.
+4. DarkAuth redirects back to the app.
+5. App calls `refreshSession()` and receives a token for the new session org.
+
+This is useful when the app does not want to build its own organization picker.
+
+## API Contract
+
+### Authorization Request
+
+`GET /authorize` should continue to accept:
+
+```text
+organization_id=
+```
+
+Rules:
+
+- If present, DarkAuth must validate active membership before code issuance.
+- If absent and exactly one active org exists, DarkAuth may select it.
+- If absent and multiple active orgs exist, DarkAuth should use the existing authorize UI selector.
+- Tokens issued from the code must contain only the selected organization's roles and permissions.
+
+### User Organizations
+
+Apps need a user-facing organization list for switcher UIs.
+
+Keep:
+
+```text
+GET /api/user/organizations
+```
+
+Response should be stable and SDK-friendly:
+
+```json
+{
+ "organizations": [
+ {
+ "organizationId": "uuid",
+ "slug": "acme",
+ "name": "Acme",
+ "status": "active",
+ "roles": [
+ { "id": "uuid", "key": "org_admin", "name": "Admin" }
+ ]
+ }
+ ]
+}
+```
+
+Rules:
+
+- Return only organizations where the current user has membership.
+- SDK helpers should filter to active organizations by default.
+- Do not leak organizations where the user is not a member.
+
+### Session Organization
+
+Keep:
+
+```text
+POST /api/user/session/organization
+```
+
+This endpoint is appropriate for same-origin hosted DarkAuth UI. It is not enough as the only app-owned third-party integration because it is CSRF protected and relies on DarkAuth first-party cookies.
+
+Rules:
+
+- Validate active membership.
+- Update `sessions.data.organizationId` and `sessions.data.organizationSlug`.
+- Audit previous and next organization.
+- Return current org metadata and an optional validated redirect target.
+
+### Token Endpoint
+
+`POST /token` should keep issuing tokens for the current session organization during refresh-token grant.
+
+Rules:
+
+- If the current session organization changes and the app performs a refresh-token grant, the new ID/access tokens should reflect the new org.
+- 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
+
+If DarkAuth later wants a non-redirect token switch, use a formal extension instead of an ad hoc parameter:
+
+- 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.
+
+This is a future optimization, not required for Atlas.
+
+## SDK Contract
+
+Add typed organization support to `@darkauth/client`.
+
+### Types
+
+```ts
+export type DarkAuthOrganization = {
+ organizationId: string;
+ slug: string;
+ name: string;
+ status: string;
+ roles?: Array<{ id: string; key: string; name: string }>;
+};
+
+export type InitiateLoginOptions = {
+ organizationId?: string;
+ returnTo?: string;
+};
+
+export type SwitchOrganizationOptions = {
+ mode?: "authorize" | "hosted";
+ returnTo?: string;
+};
+```
+
+### `initiateLogin(options?)`
+
+Update `initiateLogin` to accept an optional `organizationId`.
+
+Behavior:
+
+- Include `organization_id` in the authorization URL when supplied.
+- Preserve existing PKCE, state, scope, ZK, and redirect behavior.
+- Store safe `returnTo` state only if the SDK already owns post-callback navigation state.
+
+### `listOrganizations()`
+
+Add:
+
+```ts
+export async function listOrganizations(): Promise
+```
+
+Behavior:
+
+- Fetch from `GET /api/user/organizations`.
+- Use `credentials: include`.
+- Return active and inactive memberships as the API returns them, or add `listActiveOrganizations()` if the SDK should filter.
+- Throw a typed auth/session error on 401.
+
+### `getSessionInfo()`
+
+Add:
+
+```ts
+export async function getSessionInfo(): Promise<{
+ authenticated: boolean;
+ sub?: string;
+ email?: string | null;
+ name?: string | null;
+ organizationId?: string;
+ organizationSlug?: string | null;
+}>
+```
+
+This is useful for app chrome before a fresh OAuth callback is required.
+
+### `switchOrganization(organizationId, options?)`
+
+Add:
+
+```ts
+export async function switchOrganization(
+ organizationId: string,
+ options?: SwitchOrganizationOptions
+): Promise
+```
+
+Default behavior should be `mode: "authorize"` for third-party apps:
+
+- Call `initiateLogin({ organizationId, returnTo })`.
+- This produces a normal authorization-code flow and fresh org-scoped token.
+- The app handles the callback with existing `handleCallback()`.
+
+Hosted behavior:
+
+- Redirect to `/switch-org?organization_id=...&client_id=...&return_to=...`.
+- On return, the app calls `refreshSession({ force: true })`.
+- Use only for apps that want DarkAuth's hosted switch screen.
+
+### `refreshSession(options?)`
+
+Extend refresh:
+
+```ts
+export async function refreshSession(options?: { force?: boolean }): Promise
+```
+
+Behavior:
+
+- `force: true` should refresh even if the current in-memory ID token is still valid.
+- When DarkAuth session org changed, return a token reflecting that org.
+- Preserve existing token/cookie refresh modes.
+
+### Claims Type
+
+Extend `JwtClaims`:
+
+```ts
+export interface JwtClaims {
+ sub?: string;
+ email?: string;
+ name?: string;
+ exp?: number;
+ iat?: number;
+ iss?: string;
+ org_id?: string;
+ org_slug?: string;
+ roles?: string[];
+ permissions?: string[];
+}
+```
+
+## Client UX Guidance
+
+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.
+
+For hosted switching:
+
+- App links to `switchOrganization(orgId, { mode: "hosted" })` or directly to `/switch-org`.
+- DarkAuth owns organization selection UI.
+- App refreshes session after return.
+
+For login:
+
+- If the app has a preferred org, call `initiateLogin({ organizationId })`.
+- If the app has no preferred org, call `initiateLogin()` and let DarkAuth choose or prompt.
+
+## Security Requirements
+
+- Never trust an org selected in app UI without server-side validation.
+- Never mint tokens for an org where the user lacks active membership.
+- 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.
+- Do not include roles or permissions from non-selected orgs.
+- Audit org switching through hosted session changes and authorize-time changes.
+- Keep `ORG_CONTEXT_REQUIRED` machine-readable for developers but map it to user-facing org selection.
+
+## Acceptance Criteria
+
+- 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`.
+- 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.
+- Atlas can build a Slack-like org rail without using private DarkAuth APIs.
+
+## Implementation Checklist
+
+### API And OAuth
+
+- [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] 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.
+
+### SDK
+
+- [x] Export `DarkAuthOrganization`, `InitiateLoginOptions`, and `SwitchOrganizationOptions`.
+- [x] Extend `JwtClaims` with `org_id`, `org_slug`, `roles`, and `permissions`.
+- [x] Update `initiateLogin(options?)` to include `organization_id`.
+- [x] Add `listOrganizations()`.
+- [x] Add `getSessionInfo()`.
+- [x] Add `switchOrganization(organizationId, options?)`.
+- [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.
+
+### Hosted User UI
+
+- [x] Keep `/switch-org` as the hosted switch screen.
+- [x] Confirm `/switch-org` supports `client_id`, `return_to`, and preselected `organization_id`.
+- [x] Add copy that frames switching as choosing the active organization for connected apps.
+- [x] Ensure profile links to `/switch-org` only when more than one active org exists.
+- [x] Add visual indication of the current org in the user portal.
+
+### Tests
+
+- [x] SDK test for `initiateLogin({ organizationId })` authorization URL.
+- [x] SDK test for `listOrganizations()`.
+- [x] SDK test for `switchOrganization()` 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.
+- [x] Browser test for multi-org authorize selector.
+- [x] Browser test for `/switch-org` returning to a registered app URL.
+
+### Documentation
+
+- [x] Document app-owned organization switcher pattern.
+- [x] Document hosted switcher pattern.
+- [x] Document when to use `organization_id` on `/authorize`.
+- [x] Document token claim semantics for selected org.
+- [x] Document that apps must treat org switching as a tenant/workspace state reset.
diff --git a/specs/RBAC.md b/specs/RBAC.md
index 262a834a..28175b6f 100644
--- a/specs/RBAC.md
+++ b/specs/RBAC.md
@@ -64,7 +64,7 @@ OTP enforcement is organization-level, not role-level.
- Every organization has `force_otp` (boolean).
- `force_otp` default is `false` for all organizations.
- If `force_otp = true`, every active member of that organization must complete OTP.
-- The `default` organization may enable `force_otp`, but it is not enabled automatically unless explicitly set.
+- Any organization may enable `force_otp`, but it is never enabled automatically unless explicitly set. No organization (including any legacy `default` organization) has special `force_otp` behavior.
- The `otp_required` role is not part of this model.
## Relationships
@@ -99,9 +99,14 @@ many-to-many via `organization_member_roles`.
- `key` `text` unique
- `name` `text` not null
- `description` `text` nullable
-- `system` `boolean` default false
+- `system` `boolean` default false (instance-managed/protected; not the org-admin assignability gate)
+- `assignable` `boolean` not null default false (organization admins may assign this role)
+- `default_member` `boolean` not null default false (auto-assigned to members joining an organization)
+- `default_creator` `boolean` not null default false (auto-assigned to organization creators)
- `created_at`, `updated_at`
+Role behavior depends on these flags, not on hardcoded role keys such as `member` or `org_admin`. See `specs/ORGANISATION_REFACTOR.md` for the authoritative role-flag rules.
+
`permissions`
- keep existing table
@@ -151,8 +156,8 @@ Keep existing claims stable by default and add org-scoped claims when org contex
Two supported modes:
- explicit org context:
client passes selected organization (`organization_id`) during authorization/login continuation.
-- default org context:
-if no org provided and user has one membership, use it.
+- implicit org context:
+if no org provided and user has one active membership, use it. This is a selection fallback and does not refer to any special `Default` organization.
If multiple memberships and no org selected:
- return a deterministic error (`ORG_CONTEXT_REQUIRED`) or redirect to org picker in user portal flow.
@@ -199,10 +204,11 @@ Admin portal remains control-plane and gets organization-aware endpoints:
## Phase 1: Backfill
-- create default organization record (`default`) or one configurable bootstrap org.
-- create `organization_members` for all existing users in bootstrap org.
+- This phase reflects the original one-time RBAC backfill. Any `default` organization it produced is an ordinary organization with no special runtime behavior. Fresh installs do not create a special `Default` organization. See `specs/ORGANISATION_REFACTOR.md`.
+- (historical) create a single bootstrap organization for existing users.
+- create `organization_members` for all existing users in the bootstrap org.
- convert existing `groups` to `roles` one-time map.
-- convert existing `user_groups` assignments to membership-role assignments in bootstrap org.
+- convert existing `user_groups` assignments to membership-role assignments in the bootstrap org.
- convert existing `group_permissions` to `role_permissions`.
- set `organizations.force_otp = false` by default for all orgs.
@@ -276,7 +282,7 @@ requires product decision on redirect to org picker vs API error.
- [ ] Create idempotent backfill script: users -> bootstrap org memberships.
- [ ] Create idempotent backfill script: groups -> roles and assignments.
- [ ] Add migration verification queries and failure rollback notes.
-- [ ] Add install/bootstrap seeding for default organization and base roles.
+- [ ] Add install/bootstrap seeding for base roles and permissions only. Do not seed a special `Default` organization; new users get a personal organization at registration (see `specs/ORGANISATION_REFACTOR.md`).
Parallelization:
- This track should start first.