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
2 changes: 1 addition & 1 deletion apps/api/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type AuthEnv = Pick<
* Key behaviors:
* - Uses custom 'identity' table instead of default 'account' model for OAuth accounts
* - Allows users to create up to 5 organizations with 'owner' role as creator
* - Disables automatic ID generation (assumes custom ID logic in schema)
* - Delegates ID generation to database (schema defaults to gen_random_uuid)
* - Supports anonymous authentication alongside email/password and Google OAuth
*
* @param db Drizzle database instance - must include all required auth tables (user, session, identity, organization, member, invitation, verification)
Expand Down
2 changes: 1 addition & 1 deletion apps/api/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function createDb(db: Hyperdrive) {
onnotice: () => {}, // Suppress notices in Workers
});

return drizzle(client, { schema });
return drizzle(client, { schema, casing: "snake_case" });
}

/**
Expand Down
23 changes: 13 additions & 10 deletions apps/app/components/auth/social-login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

import { auth } from "@/lib/auth";
import { authConfig } from "@/lib/auth-config";
import { sessionQueryKey } from "@/lib/queries/session";
import { queryClient } from "@/lib/query";
import { useRouterState } from "@tanstack/react-router";
import { Button } from "@repo/ui";

interface SocialLoginProps {
Expand All @@ -12,24 +14,25 @@ interface SocialLoginProps {
}

export function SocialLogin({ onError, isDisabled }: SocialLoginProps) {
// Get returnTo from router state (already sanitized by validateSearch)
const returnTo = useRouterState({
select: (s) => (s.location.search as { returnTo?: string }).returnTo,
});

const handleGoogleLogin = async () => {
try {
onError(""); // Clear any previous errors

// Clear any stale session data before OAuth redirect
queryClient.removeQueries();
// Clear stale session before OAuth redirect
queryClient.removeQueries({ queryKey: sessionQueryKey });

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

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

// Note: This code won't execute as OAuth redirects the page
Expand Down
4 changes: 1 addition & 3 deletions apps/app/lib/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@
export const authConfig = {
// OAuth provider configuration
oauth: {
// Default callback URL after OAuth authentication - redirect to login page first
// Callback URL after OAuth authentication - returns to login page for session sync
defaultCallbackUrl: "/login",
// Final destination after login page processing
postLoginRedirect: "/",
// Supported OAuth providers
providers: ["google"] as const,
},
Expand Down
8 changes: 1 addition & 7 deletions apps/app/routes/(app)/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,9 @@ export const Route = createFileRoute("/(app)")({
// Check both user AND session exist to ensure valid auth state
// Better Auth can return partial data during edge cases
if (!session?.user || !session?.session) {
// Validate redirect URL to prevent open redirect attacks
const currentPath = location.pathname + location.search;
const safeRedirect = currentPath.startsWith("/") ? currentPath : "/";

throw redirect({
to: "/login",
search: {
redirect: safeRedirect,
},
search: { returnTo: location.href },
});
}

Expand Down
153 changes: 39 additions & 114 deletions apps/app/routes/(auth)/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,139 +2,64 @@
/* SPDX-License-Identifier: MIT */

import { LoginForm } from "@/components/auth/login-form";
import { authConfig, getSafeRedirectUrl } from "@/lib/auth-config";
import { getSafeRedirectUrl } from "@/lib/auth-config";
import { invalidateSession, sessionQueryOptions } from "@/lib/queries/session";
import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import {
createFileRoute,
isRedirect,
redirect,
useRouter,
} from "@tanstack/react-router";
import { z } from "zod";

// Sanitize returnTo at parse time - consumers get a safe value or undefined
const searchSchema = z.object({
returnTo: z
.string()
.optional()
.transform((val) => {
const safe = getSafeRedirectUrl(val);
return safe === "/" ? undefined : safe;
})
.catch(undefined),
});

export const Route = createFileRoute("/(auth)/login")({
// [AUTH GUARD] Redirect authenticated users away from login page
// WARNING: Both user AND session must exist - partial data indicates corrupted session
beforeLoad: async ({ context }) => {
// Fetch fresh session data to ensure we have the latest auth state
const session = await context.queryClient.fetchQuery(sessionQueryOptions());

if (session?.user && session?.session) {
throw redirect({ to: "/" });
validateSearch: searchSchema,
beforeLoad: async ({ context, search }) => {
try {
const session = await context.queryClient.fetchQuery(
sessionQueryOptions(),
);

// Redirect authenticated users to their destination
if (session?.user && session?.session) {
throw redirect({ to: search.returnTo ?? "/" });
}
} catch (error) {
// Re-throw redirects, show login form for fetch errors
if (isRedirect(error)) throw error;
}
},
component: LoginPage,
// [SECURITY] Prevent open redirect attacks by sanitizing all redirect URLs
// Returns "/" for non-relative or suspicious URLs
//
// [OAUTH FLOW] returnUrl signals post-OAuth callback state:
// 1. User initiates social login → redirects to provider
// 2. Provider authenticates → returns to /api/auth/callback/<provider>
// 3. API validates OAuth → redirects here with returnUrl=<destination>
// 4. Component verifies session → auto-navigates to final destination
validateSearch: (
search: Record<string, unknown>,
): { redirect: string; returnUrl?: string } => {
return {
redirect: getSafeRedirectUrl(search.redirect),
returnUrl:
typeof search.returnUrl === "string"
? getSafeRedirectUrl(search.returnUrl)
: undefined,
};
},
});

function LoginPage() {
const navigate = useNavigate();
const router = useRouter();
const queryClient = useQueryClient();
const { redirect, returnUrl } = Route.useSearch();
const [isPostOAuth, setIsPostOAuth] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const isMounted = useRef(true);

// [POST-OAUTH] Auto-redirect authenticated users after OAuth callback
// Triggered when returnUrl present (indicates return from provider)
useEffect(() => {
// Prevent React state updates on unmounted component (memory leak protection)
isMounted.current = true;

const checkPostOAuthAuth = async () => {
// returnUrl presence confirms OAuth callback - skip check otherwise
if (!returnUrl) return;

try {
setIsCheckingAuth(true);

// Fetch fresh session to verify OAuth success
const session = await queryClient.fetchQuery(sessionQueryOptions());

// Guard against unmounted component updates
if (!isMounted.current) return;

if (session?.user && session?.session) {
setIsPostOAuth(true);

// [CACHE SYNC] Invalidate stale session before navigation
await invalidateSession(queryClient);

// [AUTO REDIRECT] Route to original destination or default
const finalDestination =
returnUrl || authConfig.oauth.postLoginRedirect;
navigate({ to: finalDestination }).catch(() => {
// Hard redirect fallback ensures navigation on router failure
window.location.href = finalDestination;
});
} else {
// OAuth incomplete - display login form for retry
setIsCheckingAuth(false);
}
} catch (error) {
console.error("Post-OAuth auth check failed:", error);
if (isMounted.current) {
setIsCheckingAuth(false);
}
}
};

checkPostOAuthAuth();

// Cleanup: mark component unmounted
return () => {
isMounted.current = false;
};
}, [returnUrl, queryClient, navigate]);
const search = Route.useSearch();

async function handleSuccess() {
// [CACHE SYNC] Invalidate session cache after successful login
// WARNING: Must complete before navigation to prevent stale UI state
await invalidateSession(queryClient);

// Priority: returnUrl (OAuth flow) > redirect (standard flow)
const destination = returnUrl || redirect;

// Try router navigation first, hard redirect on failure
// NOTE: Hard redirect guarantees navigation despite router state issues
navigate({ to: destination }).catch(() => {
window.location.href = destination;
});
}

// [UI STATE] Post-OAuth loading indicator during session verification
if (isPostOAuth) {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<h2 className="text-lg font-semibold mb-2">Completing sign in...</h2>
<p className="text-muted-foreground text-sm">
You'll be redirected to your destination shortly.
</p>
</div>
</div>
);
await router.invalidate();
await router.navigate({ to: search.returnTo ?? "/" });
}

return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-3xl">
<LoginForm onSuccess={handleSuccess} isLoading={isCheckingAuth} />
<LoginForm onSuccess={handleSuccess} />
</div>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions db/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Schema (Drizzle casing: snake_case)

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

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

## References
Expand Down
14 changes: 14 additions & 0 deletions db/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,17 @@ import { organization, member } from "@/db/schema/organization";
- Missing URL: ensure `DATABASE_URL` is set and starts with `postgres://` or `postgresql://`.
- Wrong environment: confirm `ENVIRONMENT`/`NODE_ENV` matches the target and the corresponding `.env.*` file exists.
- Drift/conflicts: run `bun --filter @repo/db check`; regenerate migrations if schema and migrations diverge.

## UUID v7

The schema uses `gen_random_uuid()` by default for maximum compatibility. For time-ordered UUIDs (better index locality), replace with:

- **PostgreSQL 18+**: `uuidv7()` (native)
- **Earlier versions**: `uuid_generate_v7()` (requires [pg_uuidv7](https://github.com/fboulnois/pg_uuidv7) extension

```typescript
// Example: enable UUID v7 in schema
id: text()
.primaryKey()
.default(sql`uuidv7()`);
```
Loading