Skip to content

Commit b8773b6

Browse files
mjunaidcaclaude
andcommitted
fix(org-switch): Use Redis to pass org context during JWT generation
Problem: getAdditionalUserInfoClaim couldn't access Redis sessions (Better Auth stores sessions in Redis, not PostgreSQL), causing org switch to always fall back to the first organization. Solution: Store org context in Redis before OAuth re-auth flow: 1. OrgSwitcher calls /api/auth/set-org-context with selected orgId 2. SSO stores {userId → orgId} in Redis with 5 min TTL 3. getAdditionalUserInfoClaim reads from Redis and uses it for tenant_id 4. Redis key is deleted after use (one-time use) No database schema changes required. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cb5db66 commit b8773b6

3 files changed

Lines changed: 146 additions & 51 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { auth } from "@/lib/auth";
3+
import { headers } from "next/headers";
4+
import { redis } from "@/lib/redis";
5+
import { db } from "@/lib/db";
6+
import { member } from "@/lib/db/schema-export";
7+
import { eq } from "drizzle-orm";
8+
9+
// Redis key prefix for org context
10+
const ORG_CONTEXT_PREFIX = "org_context:";
11+
// TTL: 5 minutes (enough time for OAuth flow to complete)
12+
const ORG_CONTEXT_TTL = 300;
13+
14+
/**
15+
* POST /api/auth/set-org-context
16+
*
17+
* Stores the user's selected organization in Redis for use during JWT generation.
18+
* Called before initiating OAuth re-authentication for org switching.
19+
*
20+
* This is needed because:
21+
* - getAdditionalUserInfoClaim can't access Redis sessions
22+
* - We don't want to modify the DB schema
23+
* - The OAuth token endpoint is a back-channel request without browser context
24+
*
25+
* Request body: { organizationId: string }
26+
* Security: Validates user is a member of the requested organization.
27+
*/
28+
export async function POST(request: NextRequest) {
29+
try {
30+
// Check if Redis is available
31+
if (!redis) {
32+
console.error("[set-org-context] Redis not configured");
33+
return NextResponse.json(
34+
{ error: "Service unavailable" },
35+
{ status: 503 }
36+
);
37+
}
38+
39+
// Get current session
40+
const session = await auth.api.getSession({
41+
headers: await headers(),
42+
});
43+
44+
if (!session?.user) {
45+
return NextResponse.json(
46+
{ error: "Unauthorized" },
47+
{ status: 401 }
48+
);
49+
}
50+
51+
const body = await request.json();
52+
const { organizationId } = body;
53+
54+
if (!organizationId || typeof organizationId !== "string") {
55+
return NextResponse.json(
56+
{ error: "organizationId is required" },
57+
{ status: 400 }
58+
);
59+
}
60+
61+
// Verify user is a member of this organization
62+
const memberships = await db
63+
.select()
64+
.from(member)
65+
.where(eq(member.userId, session.user.id));
66+
67+
const isMember = memberships.some((m: { organizationId: string }) => m.organizationId === organizationId);
68+
69+
if (!isMember) {
70+
return NextResponse.json(
71+
{ error: "User is not a member of this organization" },
72+
{ status: 403 }
73+
);
74+
}
75+
76+
// Store in Redis with TTL
77+
const key = `${ORG_CONTEXT_PREFIX}${session.user.id}`;
78+
await redis.set(key, organizationId, { ex: ORG_CONTEXT_TTL });
79+
80+
console.log("[set-org-context] Stored org context:", session.user.id, "->", organizationId);
81+
82+
return NextResponse.json({
83+
success: true,
84+
organizationId,
85+
expiresIn: ORG_CONTEXT_TTL,
86+
});
87+
} catch (error) {
88+
console.error("[set-org-context] Error:", error);
89+
return NextResponse.json(
90+
{ error: "Internal server error" },
91+
{ status: 500 }
92+
);
93+
}
94+
}

apps/sso/src/lib/auth.ts

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { deviceAuthorization } from "better-auth/plugins"; // 014-mcp-oauth-stan
1313
import { db } from "./db";
1414
import * as schema from "../../auth-schema"; // Use Better Auth generated schema
1515
import { member } from "../../auth-schema";
16-
import { eq, and, inArray, desc } from "drizzle-orm";
16+
import { eq, and, inArray } from "drizzle-orm";
1717
import { Resend } from "resend";
1818
import * as nodemailer from "nodemailer";
1919
import { TRUSTED_CLIENTS, DEFAULT_ORG_ID } from "./trusted-clients";
@@ -630,40 +630,29 @@ export const auth = betterAuth({
630630
console.log("[JWT] Organization Names:", organizationNames);
631631
}
632632

633-
// Get active organization from user's most recent session
634-
// This allows org switcher to update tenant_id in JWT
633+
// Get active organization from Redis (set by /api/auth/set-org-context during org switch)
634+
// This is needed because getAdditionalUserInfoClaim can't access Redis sessions directly
635635
let activeOrgId: string | null = null;
636636

637-
// Query ALL sessions for this user to debug
638-
const allUserSessions = await db
639-
.select({
640-
id: schema.session.id,
641-
activeOrganizationId: schema.session.activeOrganizationId,
642-
updatedAt: schema.session.updatedAt,
643-
})
644-
.from(schema.session)
645-
.where(eq(schema.session.userId, user.id))
646-
.orderBy(desc(schema.session.updatedAt));
647-
648-
console.log("[JWT] All sessions for user:", user.id);
649-
console.log("[JWT] Session count:", allUserSessions.length);
650-
allUserSessions.forEach((s: { id: string; activeOrganizationId: string | null; updatedAt: Date | null }, i: number) => {
651-
console.log(`[JWT] Session ${i}: id=${s.id?.slice(0, 8)}..., activeOrgId=${s.activeOrganizationId}, updated=${s.updatedAt}`);
652-
});
653-
654-
const userSessions = allUserSessions.slice(0, 1);
655-
656-
if (userSessions.length > 0 && userSessions[0].activeOrganizationId) {
657-
// Verify the active org is one the user actually belongs to
658-
if (organizationIds.includes(userSessions[0].activeOrganizationId)) {
659-
activeOrgId = userSessions[0].activeOrganizationId;
660-
console.log("[JWT] Using activeOrganizationId from session:", activeOrgId);
661-
} else {
662-
console.log("[JWT] Session activeOrganizationId not in user's orgs, falling back");
663-
console.log("[JWT] organizationIds:", organizationIds);
637+
// Check Redis for org context (set before OAuth re-auth for org switching)
638+
if (redis) {
639+
try {
640+
const redisOrgId = await redis.get<string>(`org_context:${user.id}`);
641+
console.log("[JWT] Redis org_context:", redisOrgId);
642+
643+
if (redisOrgId && organizationIds.includes(redisOrgId)) {
644+
activeOrgId = redisOrgId;
645+
console.log("[JWT] Using activeOrganizationId from Redis:", activeOrgId);
646+
// Clean up the Redis key after use (one-time use)
647+
await redis.del(`org_context:${user.id}`);
648+
} else if (redisOrgId) {
649+
console.log("[JWT] Redis org_context not in user's orgs, falling back");
650+
}
651+
} catch (error) {
652+
console.error("[JWT] Error reading org context from Redis:", error);
664653
}
665654
} else {
666-
console.log("[JWT] No session with activeOrganizationId found");
655+
console.log("[JWT] Redis not available, skipping org context lookup");
667656
}
668657

669658
// Use active org if set, otherwise fall back to first org

apps/web/src/components/OrgSwitcher.tsx

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,27 @@ import {
1515
import { Button } from "@/components/ui/button"
1616
import { Building2, Check, Loader2 } from "lucide-react"
1717

18+
// SSO URL for API calls
19+
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL || "http://localhost:3001"
20+
1821
/**
1922
* Organization Switcher Component
2023
*
2124
* Allows users to switch between organizations they belong to.
2225
* Implements the enterprise pattern: Identity Session + Tenant-Scoped Tokens
2326
*
2427
* How it works:
25-
* 1. User clicks organization → calls organization.setActive()
26-
* 2. SSO updates session.activeOrganizationId in database
27-
* 3. Redirect to SSO login with prompt=none for silent re-auth
28-
* 4. SSO issues NEW JWT with updated tenant_id from session
29-
* 5. All subsequent requests use new JWT with new tenant_id
28+
* 1. User clicks organization → calls organization.setActive() (updates SSO session)
29+
* 2. Calls SSO /api/auth/set-org-context to store orgId in Redis
30+
* 3. Redirect to SSO login for re-auth
31+
* 4. SSO reads orgId from Redis in getAdditionalUserInfoClaim
32+
* 5. SSO issues NEW JWT with updated tenant_id
33+
* 6. All subsequent requests use new JWT with new tenant_id
3034
*
31-
* Why redirect instead of refresh?
32-
* - The JWT (taskflow_id_token cookie) is issued at login time
33-
* - router.refresh() only re-renders React components, doesn't replace JWT
34-
* - Need full OAuth flow to get new JWT with updated tenant_id claim
35-
* - prompt=none enables silent re-auth (no login screen shown)
35+
* Why Redis for org context?
36+
* - getAdditionalUserInfoClaim can't access Redis sessions (Better Auth limitation)
37+
* - No DB schema changes needed
38+
* - Short TTL (5 min) ensures cleanup
3639
*/
3740
export function OrgSwitcher() {
3841
const { user } = useAuth()
@@ -65,20 +68,29 @@ export function OrgSwitcher() {
6568
try {
6669
console.log("[OrgSwitcher] Step 1: Calling setActive for org:", orgId)
6770

68-
// Step 1: Update SSO session's active organization
71+
// Step 1: Update SSO session's active organization (for SSO UI consistency)
6972
const result = await organization.setActive({ organizationId: orgId })
7073
console.log("[OrgSwitcher] setActive result:", result)
7174

72-
// Delay to ensure session.activeOrganizationId is committed to database
73-
// before starting OAuth flow. The setActive() call updates the session,
74-
// and the OAuth flow needs to read the updated session.
75-
// 500ms should be sufficient for DB write to complete.
76-
await new Promise(resolve => setTimeout(resolve, 500))
75+
// Step 2: Store org context in Redis for JWT generation
76+
console.log("[OrgSwitcher] Step 2: Storing org context in Redis")
77+
const contextResult = await fetch(`${SSO_URL}/api/auth/set-org-context`, {
78+
method: "POST",
79+
headers: { "Content-Type": "application/json" },
80+
credentials: "include", // Send SSO session cookies
81+
body: JSON.stringify({ organizationId: orgId }),
82+
})
83+
84+
if (!contextResult.ok) {
85+
const errorData = await contextResult.json().catch(() => ({}))
86+
console.error("[OrgSwitcher] Failed to store org context:", errorData)
87+
throw new Error(errorData.error || "Failed to store organization context")
88+
}
89+
90+
console.log("[OrgSwitcher] Org context stored in Redis")
7791

78-
// Step 2: Re-authenticate to get new JWT with updated tenant_id
79-
// Pass orgId explicitly to ensure SSO uses the correct org even if session race condition
80-
// The SSO will use this org_id parameter to set tenant_id in the JWT
81-
console.log("[OrgSwitcher] Step 2: Initiating OAuth flow for new tokens with orgId:", orgId)
92+
// Step 3: Re-authenticate to get new JWT with updated tenant_id
93+
console.log("[OrgSwitcher] Step 3: Initiating OAuth flow for new tokens")
8294
await initiateLogin(orgId)
8395

8496
// Note: initiateLogin sets window.location.href which triggers navigation

0 commit comments

Comments
 (0)