Skip to content

Commit eda1222

Browse files
authored
fix(db): use gen_random_uuid for PostgreSQL compatibility (#2133)
1 parent 2d2e0f4 commit eda1222

File tree

16 files changed

+104
-171
lines changed

16 files changed

+104
-171
lines changed

apps/api/lib/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type AuthEnv = Pick<
3232
* Key behaviors:
3333
* - Uses custom 'identity' table instead of default 'account' model for OAuth accounts
3434
* - Allows users to create up to 5 organizations with 'owner' role as creator
35-
* - Disables automatic ID generation (assumes custom ID logic in schema)
35+
* - Delegates ID generation to database (schema defaults to gen_random_uuid)
3636
* - Supports anonymous authentication alongside email/password and Google OAuth
3737
*
3838
* @param db Drizzle database instance - must include all required auth tables (user, session, identity, organization, member, invitation, verification)

apps/api/lib/db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function createDb(db: Hyperdrive) {
5454
onnotice: () => {}, // Suppress notices in Workers
5555
});
5656

57-
return drizzle(client, { schema });
57+
return drizzle(client, { schema, casing: "snake_case" });
5858
}
5959

6060
/**

apps/app/components/auth/social-login.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
import { auth } from "@/lib/auth";
55
import { authConfig } from "@/lib/auth-config";
6+
import { sessionQueryKey } from "@/lib/queries/session";
67
import { queryClient } from "@/lib/query";
8+
import { useRouterState } from "@tanstack/react-router";
79
import { Button } from "@repo/ui";
810

911
interface SocialLoginProps {
@@ -12,24 +14,25 @@ interface SocialLoginProps {
1214
}
1315

1416
export function SocialLogin({ onError, isDisabled }: SocialLoginProps) {
17+
// Get returnTo from router state (already sanitized by validateSearch)
18+
const returnTo = useRouterState({
19+
select: (s) => (s.location.search as { returnTo?: string }).returnTo,
20+
});
21+
1522
const handleGoogleLogin = async () => {
1623
try {
1724
onError(""); // Clear any previous errors
1825

19-
// Clear any stale session data before OAuth redirect
20-
queryClient.removeQueries();
26+
// Clear stale session before OAuth redirect
27+
queryClient.removeQueries({ queryKey: sessionQueryKey });
2128

22-
// Capture intended destination for post-OAuth redirect
23-
const currentPath = window.location.pathname + window.location.search;
24-
const returnUrl =
25-
currentPath !== "/login"
26-
? currentPath
27-
: authConfig.oauth.postLoginRedirect;
29+
// Use sanitized returnTo or root as default
30+
const destination = returnTo || "/";
2831

29-
// Initiate Google OAuth flow - redirect to login page first
32+
// Initiate Google OAuth flow
3033
await auth.signIn.social({
3134
provider: "google",
32-
callbackURL: `${authConfig.oauth.defaultCallbackUrl}?returnUrl=${encodeURIComponent(returnUrl)}`,
35+
callbackURL: `${authConfig.oauth.defaultCallbackUrl}?returnTo=${encodeURIComponent(destination)}`,
3336
});
3437

3538
// Note: This code won't execute as OAuth redirects the page

apps/app/lib/auth-config.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@
1515
export const authConfig = {
1616
// OAuth provider configuration
1717
oauth: {
18-
// Default callback URL after OAuth authentication - redirect to login page first
18+
// Callback URL after OAuth authentication - returns to login page for session sync
1919
defaultCallbackUrl: "/login",
20-
// Final destination after login page processing
21-
postLoginRedirect: "/",
2220
// Supported OAuth providers
2321
providers: ["google"] as const,
2422
},

apps/app/routes/(app)/route.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,9 @@ export const Route = createFileRoute("/(app)")({
2828
// Check both user AND session exist to ensure valid auth state
2929
// Better Auth can return partial data during edge cases
3030
if (!session?.user || !session?.session) {
31-
// Validate redirect URL to prevent open redirect attacks
32-
const currentPath = location.pathname + location.search;
33-
const safeRedirect = currentPath.startsWith("/") ? currentPath : "/";
34-
3531
throw redirect({
3632
to: "/login",
37-
search: {
38-
redirect: safeRedirect,
39-
},
33+
search: { returnTo: location.href },
4034
});
4135
}
4236

apps/app/routes/(auth)/login.tsx

Lines changed: 39 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -2,139 +2,64 @@
22
/* SPDX-License-Identifier: MIT */
33

44
import { LoginForm } from "@/components/auth/login-form";
5-
import { authConfig, getSafeRedirectUrl } from "@/lib/auth-config";
5+
import { getSafeRedirectUrl } from "@/lib/auth-config";
66
import { invalidateSession, sessionQueryOptions } from "@/lib/queries/session";
77
import { useQueryClient } from "@tanstack/react-query";
8-
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
9-
import { useEffect, useRef, useState } from "react";
8+
import {
9+
createFileRoute,
10+
isRedirect,
11+
redirect,
12+
useRouter,
13+
} from "@tanstack/react-router";
14+
import { z } from "zod";
15+
16+
// Sanitize returnTo at parse time - consumers get a safe value or undefined
17+
const searchSchema = z.object({
18+
returnTo: z
19+
.string()
20+
.optional()
21+
.transform((val) => {
22+
const safe = getSafeRedirectUrl(val);
23+
return safe === "/" ? undefined : safe;
24+
})
25+
.catch(undefined),
26+
});
1027

1128
export const Route = createFileRoute("/(auth)/login")({
12-
// [AUTH GUARD] Redirect authenticated users away from login page
13-
// WARNING: Both user AND session must exist - partial data indicates corrupted session
14-
beforeLoad: async ({ context }) => {
15-
// Fetch fresh session data to ensure we have the latest auth state
16-
const session = await context.queryClient.fetchQuery(sessionQueryOptions());
17-
18-
if (session?.user && session?.session) {
19-
throw redirect({ to: "/" });
29+
validateSearch: searchSchema,
30+
beforeLoad: async ({ context, search }) => {
31+
try {
32+
const session = await context.queryClient.fetchQuery(
33+
sessionQueryOptions(),
34+
);
35+
36+
// Redirect authenticated users to their destination
37+
if (session?.user && session?.session) {
38+
throw redirect({ to: search.returnTo ?? "/" });
39+
}
40+
} catch (error) {
41+
// Re-throw redirects, show login form for fetch errors
42+
if (isRedirect(error)) throw error;
2043
}
2144
},
2245
component: LoginPage,
23-
// [SECURITY] Prevent open redirect attacks by sanitizing all redirect URLs
24-
// Returns "/" for non-relative or suspicious URLs
25-
//
26-
// [OAUTH FLOW] returnUrl signals post-OAuth callback state:
27-
// 1. User initiates social login → redirects to provider
28-
// 2. Provider authenticates → returns to /api/auth/callback/<provider>
29-
// 3. API validates OAuth → redirects here with returnUrl=<destination>
30-
// 4. Component verifies session → auto-navigates to final destination
31-
validateSearch: (
32-
search: Record<string, unknown>,
33-
): { redirect: string; returnUrl?: string } => {
34-
return {
35-
redirect: getSafeRedirectUrl(search.redirect),
36-
returnUrl:
37-
typeof search.returnUrl === "string"
38-
? getSafeRedirectUrl(search.returnUrl)
39-
: undefined,
40-
};
41-
},
4246
});
4347

4448
function LoginPage() {
45-
const navigate = useNavigate();
49+
const router = useRouter();
4650
const queryClient = useQueryClient();
47-
const { redirect, returnUrl } = Route.useSearch();
48-
const [isPostOAuth, setIsPostOAuth] = useState(false);
49-
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
50-
const isMounted = useRef(true);
51-
52-
// [POST-OAUTH] Auto-redirect authenticated users after OAuth callback
53-
// Triggered when returnUrl present (indicates return from provider)
54-
useEffect(() => {
55-
// Prevent React state updates on unmounted component (memory leak protection)
56-
isMounted.current = true;
57-
58-
const checkPostOAuthAuth = async () => {
59-
// returnUrl presence confirms OAuth callback - skip check otherwise
60-
if (!returnUrl) return;
61-
62-
try {
63-
setIsCheckingAuth(true);
64-
65-
// Fetch fresh session to verify OAuth success
66-
const session = await queryClient.fetchQuery(sessionQueryOptions());
67-
68-
// Guard against unmounted component updates
69-
if (!isMounted.current) return;
70-
71-
if (session?.user && session?.session) {
72-
setIsPostOAuth(true);
73-
74-
// [CACHE SYNC] Invalidate stale session before navigation
75-
await invalidateSession(queryClient);
76-
77-
// [AUTO REDIRECT] Route to original destination or default
78-
const finalDestination =
79-
returnUrl || authConfig.oauth.postLoginRedirect;
80-
navigate({ to: finalDestination }).catch(() => {
81-
// Hard redirect fallback ensures navigation on router failure
82-
window.location.href = finalDestination;
83-
});
84-
} else {
85-
// OAuth incomplete - display login form for retry
86-
setIsCheckingAuth(false);
87-
}
88-
} catch (error) {
89-
console.error("Post-OAuth auth check failed:", error);
90-
if (isMounted.current) {
91-
setIsCheckingAuth(false);
92-
}
93-
}
94-
};
95-
96-
checkPostOAuthAuth();
97-
98-
// Cleanup: mark component unmounted
99-
return () => {
100-
isMounted.current = false;
101-
};
102-
}, [returnUrl, queryClient, navigate]);
51+
const search = Route.useSearch();
10352

10453
async function handleSuccess() {
105-
// [CACHE SYNC] Invalidate session cache after successful login
106-
// WARNING: Must complete before navigation to prevent stale UI state
10754
await invalidateSession(queryClient);
108-
109-
// Priority: returnUrl (OAuth flow) > redirect (standard flow)
110-
const destination = returnUrl || redirect;
111-
112-
// Try router navigation first, hard redirect on failure
113-
// NOTE: Hard redirect guarantees navigation despite router state issues
114-
navigate({ to: destination }).catch(() => {
115-
window.location.href = destination;
116-
});
117-
}
118-
119-
// [UI STATE] Post-OAuth loading indicator during session verification
120-
if (isPostOAuth) {
121-
return (
122-
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
123-
<div className="w-full max-w-sm text-center">
124-
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
125-
<h2 className="text-lg font-semibold mb-2">Completing sign in...</h2>
126-
<p className="text-muted-foreground text-sm">
127-
You'll be redirected to your destination shortly.
128-
</p>
129-
</div>
130-
</div>
131-
);
55+
await router.invalidate();
56+
await router.navigate({ to: search.returnTo ?? "/" });
13257
}
13358

13459
return (
13560
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
13661
<div className="w-full max-w-sm md:max-w-3xl">
137-
<LoginForm onSuccess={handleSuccess} isLoading={isCheckingAuth} />
62+
<LoginForm onSuccess={handleSuccess} />
13863
</div>
13964
</div>
14065
);

db/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## Schema (Drizzle casing: snake_case)
44

55
- user, session, identity, verification, organization, member, team, team_member, invitation, passkey.
6-
- UUID v7 ids via `uuid_generate_v7()` from the `pg_uuidv7` extension (Neon has it pre-installed).
6+
- UUID ids via `gen_random_uuid()` (built-in). For UUID v7, use `uuidv7()` (PostgreSQL 18+) or `uuid_generate_v7()` (pg_uuidv7 extension).
77
- Timestamps use `timestamp(..., withTimezone: true, mode: "date")` with `defaultNow()` and `$onUpdate`.
88
- Indexes on all FK columns; composite uniques on membership, team membership, identity provider/account, and invitation (org/email/team).
99
- No FKs on `session.activeOrganizationId/activeTeamId` to stay compatible with Better Auth's dynamic context.
@@ -32,7 +32,7 @@
3232
## Conventions
3333

3434
- Keep singular table names and snake_case columns; avoid adding FKs to dynamic Better Auth fields.
35-
- Preserve UUID v7 defaults; do not swap to `gen_random_uuid()`.
35+
- Use `gen_random_uuid()` for portability. UUID v7 alternatives: `uuidv7()` (PG 18+) or `uuid_generate_v7()` (pg_uuidv7).
3636
- Keep `updatedAt` on all tables for audit trails.
3737

3838
## References

db/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,17 @@ import { organization, member } from "@/db/schema/organization";
8989
- Missing URL: ensure `DATABASE_URL` is set and starts with `postgres://` or `postgresql://`.
9090
- Wrong environment: confirm `ENVIRONMENT`/`NODE_ENV` matches the target and the corresponding `.env.*` file exists.
9191
- Drift/conflicts: run `bun --filter @repo/db check`; regenerate migrations if schema and migrations diverge.
92+
93+
## UUID v7
94+
95+
The schema uses `gen_random_uuid()` by default for maximum compatibility. For time-ordered UUIDs (better index locality), replace with:
96+
97+
- **PostgreSQL 18+**: `uuidv7()` (native)
98+
- **Earlier versions**: `uuid_generate_v7()` (requires [pg_uuidv7](https://github.com/fboulnois/pg_uuidv7) extension
99+
100+
```typescript
101+
// Example: enable UUID v7 in schema
102+
id: text()
103+
.primaryKey()
104+
.default(sql`uuidv7()`);
105+
```

0 commit comments

Comments
 (0)