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
7 changes: 6 additions & 1 deletion packages/api/src/controllers/user/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ 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));
return new Set(
scope
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean)
);
}

function sessionCoversAuthorization(
Expand Down
87 changes: 82 additions & 5 deletions packages/api/src/controllers/user/oauthEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Readable } from "node:stream";
import { test } from "node:test";
import { createPglite } from "../../db/pglite.ts";
import { authCodes, clients, organizationMembers, organizations, users } from "../../db/schema.ts";
import { InvalidGrantError, UnauthorizedClientError } from "../../errors.ts";
import { ForbiddenError, InvalidGrantError, UnauthorizedClientError } from "../../errors.ts";
import { generateEdDSAKeyPair, signJWT, storeKeyPair, verifyJWT } from "../../services/jwks.ts";
import {
createSession,
Expand All @@ -17,6 +17,7 @@ import {
import type { Context } from "../../types.ts";
import { sha256Base64Url } from "../../utils/crypto.ts";
import { postIntrospect } from "./introspect.ts";
import { getOrganizations } from "./organizations.ts";
import { postRevoke } from "./revoke.ts";
import { postToken, postTokenOrganization } from "./token.ts";
import { handleUserinfo } from "./userinfo.ts";
Expand Down Expand Up @@ -498,22 +499,98 @@ test("token organization switch mints target-org tokens from current app token",
await postTokenOrganization(context, request, response);

const json = response.json as Record<string, unknown>;
const idTokenClaims = await verifyJWT(context, json.id_token as string, "public-refresh-client");
const idTokenClaims = await verifyJWT(
context,
json.id_token as string,
"public-refresh-client"
);
const accessTokenClaims = await verifyJWT(
context,
json.access_token as string,
"public-refresh-client"
);
const setCookie = response.getHeader("set-cookie");
assert.equal(response.statusCode, 200);
assert.equal(json.token_type, "Bearer");
assert.equal(json.scope, "openid profile");
assert.equal(typeof json.refresh_token, "string");
assert.equal(idTokenClaims.org_id, targetOrganization.id);
assert.equal(idTokenClaims.org_slug, "second-org");
assert.equal(accessTokenClaims.org_id, targetOrganization.id);
assert.ok(Array.isArray(setCookie));
assert.ok(setCookie.some((value) => value.includes(USER_REFRESH_COOKIE_NAME)));
assert.equal(response.getHeader("set-cookie"), undefined);
} finally {
await cleanup();
}
});

test("token organization switch rejects ID tokens", async () => {
const { context, cleanup } = await createContext();
try {
await createUser(context);
const targetOrganization = await createUserOrganization(context);
await createPublicRefreshClient(context);
const idToken = await signJWT(
context,
{
iss: context.config.issuer,
sub: "user-sub",
aud: "public-refresh-client",
},
"5m"
);
const request = createRequest({
method: "POST",
url: "/token/organization",
authorization: `Bearer ${idToken}`,
body: JSON.stringify({
organization_id: targetOrganization.id,
client_id: "public-refresh-client",
}),
});

await assert.rejects(
() => postTokenOrganization(context, request, createResponse()),
(error) => error instanceof ForbiddenError && error.message === "Access token required"
);
} finally {
await cleanup();
}
});

test("organizations list accepts current app access token without session cookie", async () => {
const { context, cleanup } = await createContext();
try {
await createUser(context);
await createUserOrganization(context);
await createPublicRefreshClient(context);
const accessToken = await signJWT(
context,
{
iss: context.config.issuer,
sub: "user-sub",
aud: "public-refresh-client",
azp: "public-refresh-client",
scope: "openid profile",
token_use: "access",
grant_type: "authorization_code",
},
"5m"
);
const request = createRequest({
method: "GET",
url: "/organizations",
authorization: `Bearer ${accessToken}`,
});
const response = createResponse();

await getOrganizations(context, request, response);

const json = response.json as {
organizations: Array<{ organizationId: string; slug: string; status: string }>;
};
assert.equal(response.statusCode, 200);
assert.equal(json.organizations.length, 1);
assert.equal(json.organizations[0]?.slug, "test-org");
assert.equal(json.organizations[0]?.status, "active");
} finally {
await cleanup();
}
Expand Down
51 changes: 48 additions & 3 deletions packages/api/src/controllers/user/organizations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { z } from "zod/v4";
import { ForbiddenError, UnauthorizedClientError, UnauthorizedError } from "../../errors.ts";
import { genericErrors } from "../../http/openapi-helpers.ts";
import { getClient } from "../../models/clients.ts";
import {
assignMemberRoles,
createOrganization,
Expand All @@ -14,6 +16,7 @@ import {
removeOrganizationMember,
requireOrganizationMembership,
} from "../../models/organizations.ts";
import { verifyJWT } from "../../services/jwks.ts";
import { requireSession } from "../../services/sessions.ts";
import type { Context, ControllerSchema } from "../../types.ts";
import { withAudit } from "../../utils/auditWrapper.ts";
Expand Down Expand Up @@ -58,13 +61,55 @@ const AssignableRoleSchema = z.object({
description: z.string().nullable().optional(),
});

function getBearerToken(request: IncomingMessage): string | null {
const auth = request.headers.authorization;
if (typeof auth !== "string") return null;
const [scheme, token] = auth.split(" ");
if (!scheme || scheme.toLowerCase() !== "bearer" || !token) {
throw new UnauthorizedError("Bearer token required");
}
return token;
}

function resolveTokenClientId(payload: import("jose").JWTPayload): string {
if (typeof payload.azp === "string" && payload.azp.length > 0) return payload.azp;
if (typeof payload.aud === "string" && payload.aud.length > 0) return payload.aud;
if (Array.isArray(payload.aud)) {
const clientId = payload.aud.find((audience) => typeof audience === "string");
if (typeof clientId === "string" && clientId.length > 0) return clientId;
}
throw new ForbiddenError("Token was not issued to a known client");
}

async function getOrganizationsUserSub(context: Context, request: IncomingMessage) {
const token = getBearerToken(request);
if (!token) {
const session = await requireSession(context, request, false);
return session.sub as string;
}
let payload: import("jose").JWTPayload;
try {
payload = await verifyJWT(context, token);
} catch {
throw new UnauthorizedError("Invalid bearer token");
}
if (payload.token_use !== "access") throw new ForbiddenError("Access token required");
if (payload.grant_type === "client_credentials") throw new ForbiddenError("User token required");
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
throw new UnauthorizedError("User token required");
}
const client = await getClient(context, resolveTokenClientId(payload));
if (!client) throw new UnauthorizedClientError("Unknown client");
return payload.sub;
}

export async function getOrganizations(
context: Context,
request: IncomingMessage,
response: ServerResponse
) {
const session = await requireSession(context, request, false);
const organizations = await listOrganizationsForUser(context, session.sub as string);
const userSub = await getOrganizationsUserSub(context, request);
const organizations = await listOrganizationsForUser(context, userSub);
sendJson(response, 200, { organizations });
}

Expand Down Expand Up @@ -277,7 +322,7 @@ export const organizationsSchema = {
tags: ["Organizations"],
summary: "List organizations",
description:
"Lists the current user's active organization memberships with role summaries for app switcher UIs.",
"Lists the current user's active organization memberships with role summaries for app switcher UIs. Accepts either a first-party session cookie or a current app access token in the Authorization header.",
responses: {
200: {
description: "OK",
Expand Down
11 changes: 4 additions & 7 deletions packages/api/src/controllers/user/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ function tokenAudienceMatches(audience: unknown, clientId: string): boolean {
}

function assertTokenIssuedToClient(payload: import("jose").JWTPayload, clientId: string): void {
if (payload.token_use !== "access") {
throw new ForbiddenError("Access token required");
}
if (typeof payload.azp === "string" && payload.azp !== clientId) {
throw new ForbiddenError("Token was not issued to this client");
}
Expand Down Expand Up @@ -314,9 +317,7 @@ export const postTokenOrganization = withRateLimit("token")(
const user = await getUserBySub(context, payload.sub);
if (!user) throw new InvalidGrantError("User not found");

const { getUserOrgAccess, resolveOrganizationContext } = await import(
"../../models/rbac.ts"
);
const { getUserOrgAccess, resolveOrganizationContext } = await import("../../models/rbac.ts");
const { organizationId, organizationSlug } = await resolveOrganizationContext(
context,
user.sub,
Expand Down Expand Up @@ -410,10 +411,6 @@ export const postTokenOrganization = withRateLimit("token")(
} satisfies SessionData;
const s = await createSession(context, "user", sessionData);
tokenResponse.refresh_token = s.refreshToken;
const ttlSeconds = await getSessionTtlSeconds(context, "user");
const refreshTtlSeconds = await getRefreshTokenTtlSeconds(context, "user");
issueSessionCookies(response, s.sessionId, ttlSeconds, false);
issueRefreshTokenCookie(response, s.refreshToken, refreshTtlSeconds, false);
}

sendJson(response, 200, tokenResponse);
Expand Down
4 changes: 1 addition & 3 deletions packages/api/src/http/routers/userRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,7 @@ export function createUserRouter(context: Context) {
].includes(pathname);
const isOAuthPost =
method === "POST" &&
["/token", "/token/organization", "/userinfo", "/introspect", "/revoke"].includes(
pathname
);
["/token", "/token/organization", "/userinfo", "/introspect", "/revoke"].includes(pathname);
const isScimRequest = pathname.startsWith("/scim/v2/");
const needsCsrf =
!["GET", "HEAD", "OPTIONS"].includes(method) &&
Expand Down
8 changes: 4 additions & 4 deletions packages/darkauth-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,23 +118,23 @@ If `tokenStorage: 'localStorage'` or `drkStorage: 'localStorage'` is configured

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 changing the DarkAuth session organization so the app receives tokens for the newly selected organization even if the current in-memory ID token has not expired.
Use `{ force: true }` after hosted first-party organization changes so the app receives tokens for the newly selected 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<DarkAuthOrganization[]>`

Returns the current user's organizations for app-owned switcher UI. Use `status` to decide which memberships are selectable.
Returns the current user's organizations for app-owned switcher UI. When the SDK has a current app access token, the request is authorized with `Authorization: Bearer <access_token>` and does not depend on DarkAuth session cookies. 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<AuthSession | null>`

Switches the selected organization. The default `silent` mode updates the DarkAuth session organization, forces a token refresh, and returns the refreshed session. `authorize` mode starts a new authorization-code flow. `hosted` mode redirects to DarkAuth's `/switch-org` page.
Switches the selected organization. The default `token` mode exchanges the current app access token for fresh tokens scoped to the selected organization. `authorize` mode starts a new authorization-code flow. `hosted` mode redirects to DarkAuth's `/switch-org` page.

#### App-owned switcher

Expand All @@ -156,7 +156,7 @@ async function selectOrganization(organizationId: string) {
}
```

After the refresh, 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`.
After the exchange, 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`.

Use `mode: 'authorize'` when a deployment should re-enter the redirect-based OAuth flow for every organization switch.

Expand Down
24 changes: 7 additions & 17 deletions packages/darkauth-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export type InitiateLoginOptions = {
};

export type SwitchOrganizationOptions = {
mode?: "token" | "silent" | "authorize" | "hosted";
mode?: "token" | "authorize" | "hosted";
returnTo?: string;
};

Expand Down Expand Up @@ -763,7 +763,12 @@ export async function refreshSession(
}

export async function listOrganizations(): Promise<DarkAuthOrganization[]> {
const accessToken =
memorySession?.accessToken ||
(tokenStorageMode() === "localStorage" ? getStoredAccessToken() : null);
const headers = accessToken ? { authorization: `Bearer ${accessToken}` } : undefined;
const response = await fetch(rootEndpoint("/api/user/organizations"), {
headers,
credentials: fetchCredentials(),
});
if (!response.ok) throw await errorForResponse(response);
Expand Down Expand Up @@ -808,7 +813,7 @@ export async function switchOrganization(
const mode = options.mode || "token";
if (mode === "token") {
const current = getStoredSession();
const bearerToken = current?.accessToken || current?.idToken;
const bearerToken = current?.accessToken;
if (!current || !bearerToken) {
await initiateLogin({ organizationId, returnTo: options.returnTo });
return null;
Expand Down Expand Up @@ -845,21 +850,6 @@ export async function switchOrganization(
refreshToken,
});
}
if (mode === "silent") {
const response = await fetch(rootEndpoint("/api/user/session/organization"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
organization_id: organizationId,
return_to: options.returnTo,
client_id: cfg.clientId,
}),
credentials: fetchCredentials(),
});
if (!response.ok) throw await errorForResponse(response);
await response.json().catch(() => null);
return await refreshSession({ force: true });
}
if (mode === "authorize") {
await initiateLogin({ organizationId, returnTo: options.returnTo });
return null;
Expand Down
20 changes: 20 additions & 0 deletions packages/darkauth-client/tests/organizations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ function setupEnvironment() {

test("listOrganizations fetches user organizations with roles", async () => {
setupEnvironment();
setConfig({ tokenStorage: "localStorage" });
globalThis.localStorage.setItem("access_token", "current-access-token");
globalThis.fetch = async (url, init) => {
assert.equal(url, "https://issuer.example/api/user/organizations");
assert.equal(init.credentials, "include");
assert.equal(init.headers.authorization, "Bearer current-access-token");
return {
ok: true,
json: async () => ({
Expand Down Expand Up @@ -71,6 +74,23 @@ test("listOrganizations fetches user organizations with roles", async () => {
]);
});

test("listOrganizations falls back to session cookies before app tokens exist", async () => {
setupEnvironment();
globalThis.fetch = async (url, init) => {
assert.equal(url, "https://issuer.example/api/user/organizations");
assert.equal(init.credentials, "include");
assert.equal(init.headers, undefined);
return {
ok: true,
json: async () => ({ organizations: [] }),
};
};

const organizations = await listOrganizations();

assert.deepEqual(organizations, []);
});

test("listOrganizations throws typed unauthenticated error on 401", async () => {
setupEnvironment();
globalThis.fetch = async () => ({
Expand Down
Loading
Loading