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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions packages/api/src/controllers/user/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ import { parseQueryParams } from "../../utils/http.ts";
import { validateCodeChallenge } from "../../utils/pkce.ts";
import { resolveGrantedScopes } from "./token.ts";

function parseScopeSet(scope: unknown): Set<string> {
if (typeof scope !== "string") return new Set();
return new Set(scope.split(/\s+/).map((item) => item.trim()).filter(Boolean));
}

function sessionCoversAuthorization(
sessionData: Record<string, unknown> | null | undefined,
clientId: string,
grantedScopes: string[]
): boolean {
if (!sessionData || sessionData.clientId !== clientId) return false;
const sessionScopes = parseScopeSet(sessionData.scope);
if (sessionScopes.size === 0) return false;
return grantedScopes.every((scope) => sessionScopes.has(scope));
}

export const AuthorizationRequestSchema = z.object({
client_id: z.string().min(1, { message: "client_id is required" }),
redirect_uri: z.string().min(1, { message: "redirect_uri is required" }),
Expand Down Expand Up @@ -105,11 +121,14 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize(

const sessionId = getSessionId(request);
let userSub: string | undefined;
let sessionData: Record<string, unknown> | null = null;

if (sessionId) {
const sessionData = await getSession(context, sessionId);
userSub = sessionData?.sub;
sessionData = (await getSession(context, sessionId)) as Record<string, unknown> | null;
userSub = typeof sessionData?.sub === "string" ? sessionData.sub : undefined;
}
const autoFinalize =
!zkPubKid && sessionCoversAuthorization(sessionData, authRequest.client_id, grantedScopes);

await createPendingAuth(context, {
requestId,
Expand Down Expand Up @@ -154,6 +173,7 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize(
if (authRequest.redirect_uri) qs.set("redirect_uri", authRequest.redirect_uri);
if (authRequest.state) qs.set("state", authRequest.state);
if (authRequest.organization_id) qs.set("organization_id", authRequest.organization_id);
if (autoFinalize) qs.set("auto_finalize", "1");
const redirectTo = `/${qs.toString() ? `?${qs.toString()}` : ""}`;
response.statusCode = 302;
response.setHeader("Location", redirectTo);
Expand Down
8 changes: 0 additions & 8 deletions packages/api/src/http/createServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,6 @@ test("user CORS allows SDK user endpoints for registered public SPA origins", ()
isUserCorsOriginAllowed("/api/user/session", "https://atlas.wylde.net", corsPolicy),
true
);
assert.equal(
isUserCorsOriginAllowed(
"/api/user/session/organization",
"https://atlas.wylde.net",
corsPolicy
),
true
);
});

test("user CORS rejects SDK user endpoints for unregistered origins", () => {
Expand Down
3 changes: 1 addition & 2 deletions packages/api/src/http/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ function isPublicSpaCorsPath(pathname: string): boolean {
pathname === "/revoke" ||
pathname === "/api/revoke" ||
pathname === "/api/user/organizations" ||
pathname === "/api/user/session" ||
pathname === "/api/user/session/organization"
pathname === "/api/user/session"
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/darkauth-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ export async function switchOrganization(
organizationId: string,
options: SwitchOrganizationOptions = {}
): Promise<AuthSession | null> {
const mode = options.mode || "silent";
const mode = options.mode || "authorize";
if (mode === "silent") {
const response = await fetch(rootEndpoint("/api/user/session/organization"), {
method: "POST",
Expand Down
43 changes: 6 additions & 37 deletions packages/darkauth-client/tests/initiateLogin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,6 @@ function createLocation() {
};
}

function token(claims = {}) {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "none", typ: "JWT" })}.${encode({
sub: "user-1",
exp: Math.floor(Date.now() / 1000) + 3600,
...claims,
})}.sig`;
}

test("initiateLogin adds ZK parameters when zk is true", async () => {
setupEnvironment();
const { location, getAssignedUrl } = createLocation();
Expand Down Expand Up @@ -104,33 +95,10 @@ test("initiateLogin includes organization_id when organizationId is supplied", a
assert.equal(url.searchParams.get("organization_id"), "8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
});

test("switchOrganization updates the session organization and refreshes by default", async () => {
test("switchOrganization starts authorize flow by default", async () => {
setupEnvironment();
const { location, getAssignedUrl } = createLocation();
globalThis.location = location;
const idToken = token({ org_id: "8f9778b7-0f1d-46cb-ae32-74f03300f6ff" });
const calls = [];
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), init });
const parsed = new URL(String(url));
if (parsed.pathname === "/api/user/session/organization") {
assert.equal(init.method, "POST");
assert.equal(init.credentials, "include");
const body = JSON.parse(init.body);
assert.equal(body.organization_id, "8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
assert.equal(body.client_id, "client-id");
return { ok: true, json: async () => ({ organizationId: body.organization_id }) };
}
if (parsed.pathname === "/token") {
assert.equal(init.method, "POST");
assert.equal(init.credentials, "include");
const body = new URLSearchParams(init.body);
assert.equal(body.get("grant_type"), "refresh_token");
assert.equal(body.get("client_id"), "client-id");
return { ok: true, json: async () => ({ id_token: idToken }) };
}
throw new Error(`Unexpected fetch ${url}`);
};
setConfig({
issuer: "https://issuer.example",
clientId: "client-id",
Expand All @@ -139,11 +107,12 @@ test("switchOrganization updates the session organization and refreshes by defau
discovery: false,
});

const session = await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
await switchOrganization("8f9778b7-0f1d-46cb-ae32-74f03300f6ff");

assert.equal(session.idToken, idToken);
assert.equal(getAssignedUrl(), "");
assert.equal(calls.length, 2);
const url = new URL(getAssignedUrl());
assert.equal(url.pathname, "/authorize");
assert.equal(url.searchParams.get("organization_id"), "8f9778b7-0f1d-46cb-ae32-74f03300f6ff");
assert.equal(url.searchParams.get("client_id"), "client-id");
});

test("switchOrganization can start authorize flow", async () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/user-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ interface AuthRequest {
state?: string;
zkPub?: string;
organizationId?: string;
autoFinalize?: boolean;
}

function decodeBase64Url(value: string): string {
Expand Down Expand Up @@ -166,6 +167,7 @@ function AppContent() {
const state = params.get("state") || undefined;
const zkPub = params.get("zk_pub") || undefined;
const organizationId = params.get("organization_id") || undefined;
const autoFinalize = params.get("auto_finalize") === "1";
setAuthRequest((current) => {
if (
current &&
Expand All @@ -182,6 +184,7 @@ function AppContent() {
current.state === state &&
current.zkPub === zkPub &&
current.organizationId === organizationId &&
current.autoFinalize === autoFinalize &&
current.scopes.join(" ") === scopes.join(" ")
) {
return current;
Expand All @@ -200,6 +203,7 @@ function AppContent() {
state,
zkPub,
organizationId,
autoFinalize,
};
});
setAuthRequestSearch(search);
Expand Down
16 changes: 16 additions & 0 deletions packages/user-ui/src/components/Authorize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ interface AuthorizeProps {
state?: string;
zkPub?: string;
organizationId?: string;
autoFinalize?: boolean;
unlockPolicy?: UnlockPolicy;
};
sessionData: {
Expand Down Expand Up @@ -110,6 +111,7 @@ export default function Authorize({
const [deviceApprovalLoading, setDeviceApprovalLoading] = useState(false);
const [deviceApprovalStatus, setDeviceApprovalStatus] = useState<string | null>(null);
const deviceApprovalPollRef = useRef<number | null>(null);
const autoFinalizeStartedRef = useRef(false);
const [selectedOrganizationId, setSelectedOrganizationId] = useState(
authRequest.organizationId || sessionData.organizationId || ""
);
Expand Down Expand Up @@ -640,6 +642,20 @@ export default function Authorize({
}
};

useEffect(() => {
if (
!authRequest.autoFinalize ||
authRequest.hasZk ||
organizationsLoading ||
loading ||
autoFinalizeStartedRef.current
) {
return;
}
autoFinalizeStartedRef.current = true;
handleAuthorize(true);
});

const generateNewKeys = async () => {
logger.debug({ sub: sessionData.sub }, "[Authorize] generateNewKeys start");
if (!isUnlockMethodAllowed(unlockPolicy, "new_key")) {
Expand Down
52 changes: 19 additions & 33 deletions specs/ORG_SWITCHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,19 @@ The client does not currently expose typed organization helpers, and `initiateLo

DarkAuth should treat organization switching as selecting a new authorization context. The selected organization is not just UI state. It must be reflected in freshly issued tokens.

Recommended app-owned flow for first-party browser apps with an existing DarkAuth session:
Recommended app-owned flow:

1. App asks DarkAuth for active organizations.
2. User selects an organization inside the app.
3. App calls `switchOrganization(<selected org>)`.
4. DarkAuth validates the user's active membership and updates the first-party session organization.
5. The SDK forces a token refresh.
6. App receives a new org-scoped session/token.
7. App clears tenant-local state and loads data for the selected org.
4. The SDK starts a new authorization request with `organization_id=<selected org>`.
5. DarkAuth validates the user's active membership.
6. DarkAuth skips repeat consent when the current browser session was already issued for the same client and covers the requested scopes.
7. DarkAuth returns an authorization code to the registered app callback.
8. App handles the callback and receives a new org-scoped session/token.
9. App clears tenant-local state and loads data for the selected org.

Authorize fallback flow:

1. App starts a new authorization request with `organization_id=<selected org>`.
2. DarkAuth validates the user's active membership.
3. DarkAuth skips repeat consent when the client, user, organization, and requested scopes are already authorized.
4. DarkAuth returns an authorization code to the registered app callback.
5. App handles the callback and receives a new org-scoped session/token.
6. App clears tenant-local state and loads data for the selected org.

The authorize fallback is safest for stricter third-party deployments because it stays inside OAuth redirect, PKCE, state, redirect URI, and token issuance rules.
This stays inside OAuth redirect, PKCE, state, redirect URI, and token issuance rules while avoiding the repeated approval screen for already-authorized sessions.

Hosted fallback flow:

Expand Down Expand Up @@ -253,19 +246,18 @@ export async function switchOrganization(
): Promise<AuthSession | null>
```

Default behavior should be `mode: "silent"`:

- Call `POST /api/user/session/organization`.
- Force `refreshSession({ force: true })`.
- Return the refreshed org-scoped session.
- This avoids repeat consent when the browser already has a valid DarkAuth session and the client remains authorized for the requested scopes.

Authorize behavior:
Default behavior should be `mode: "authorize"`:

- Call `initiateLogin({ organizationId, returnTo })`.
- This produces a normal authorization-code flow and fresh org-scoped token.
- The app handles the callback with existing `handleCallback()`.
- Use `mode: "authorize"` when a deployment wants redirect-based OAuth controls for every switch.
- DarkAuth may auto-finalize the request without showing consent when the browser session was already issued for the same client and covers the requested scopes.

Silent behavior:

- `mode: "silent"` may call `POST /api/user/session/organization` and force `refreshSession({ force: true })`.
- Use this only in trusted first-party contexts where DarkAuth's same-origin/CSRF requirements can be satisfied.
- Public cross-origin SPAs should use the default authorize behavior.

Hosted behavior:

Expand Down Expand Up @@ -313,11 +305,6 @@ For app-owned Slack-style switching:
- App shows organization rail using `listOrganizations()`.
- Active item is derived from current token `org_id`.
- Clicking another org calls `switchOrganization(orgId)`.
- App validates the refreshed token's `org_id`, clears tenant-local state, and reloads.

For authorize-based switching:

- App calls `switchOrganization(orgId, { mode: "authorize" })`.
- App handles callback, validates new `org_id`, clears tenant-local state, and reloads.

For hosted switching:
Expand Down Expand Up @@ -347,7 +334,7 @@ For login:

- An app can list the signed-in user's organizations through `@darkauth/client`.
- An app can start login for a specific organization through `@darkauth/client`.
- An app can switch organizations through `@darkauth/client` and receive a fresh token with the selected `org_id` without a repeat authorize/consent screen when the DarkAuth session can be used silently.
- An app can switch organizations through `@darkauth/client` and receive a fresh token with the selected `org_id` without a repeat authorize/consent screen when the existing session already covers the same client and scopes.
- The existing hosted `/switch-org` flow remains available and documented.
- Refreshing after a hosted switch returns tokens for the new session organization.
- Tokens contain roles and permissions only for the selected organization.
Expand All @@ -372,7 +359,7 @@ For login:
- [x] Add `listOrganizations()`.
- [x] Add `getSessionInfo()`.
- [x] Add `switchOrganization(organizationId, options?)`.
- [x] Default `switchOrganization(organizationId)` to session-org switch plus forced refresh.
- [x] Default `switchOrganization(organizationId)` to authorize-code org switching.
- [x] Add `refreshSession({ force })`.
- [x] Add typed errors for unauthenticated session, invalid org, and org context required.
- [x] Update SDK README examples for app-owned and hosted org switching.
Expand All @@ -389,8 +376,7 @@ For login:

- [x] SDK test for `initiateLogin({ organizationId })` authorization URL.
- [x] SDK test for `listOrganizations()`.
- [x] SDK test for `switchOrganization()` silent mode.
- [x] SDK test for `switchOrganization()` authorize mode.
- [x] SDK test for `switchOrganization()` default authorize mode.
- [x] SDK test for hosted switch URL generation.
- [x] SDK test for `refreshSession({ force: true })`.
- [x] API test that hosted session switch plus refresh mints token for new org.
Expand Down
Loading