diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 95e5ae8..f5a1897 100755 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,23 +1,45 @@ -"use client"; +"use client" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { LoginForm } from "@/components/auth/login-form"; -import { PasswordlessForm } from "@/components/auth/passwordless-form"; +import { Suspense } from "react" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { LoginForm } from "@/components/auth/login-form" +import { PasswordlessForm } from "@/components/auth/passwordless-form" +import { SocialLoginButtons } from "@/components/auth/social-login-buttons" +import { Separator } from "@/components/ui/separator" export default function LoginPage() { return ( -
+
-

Welcome back

+

+ Welcome back +

Sign in to your account

+ + + + +
+
+ +
+
+ + or continue with + +
+
+ Password - Send Code + + Send Code + @@ -29,5 +51,5 @@ export default function LoginPage() {
- ); + ) } diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 747c559..ebd9357 100755 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -1,8 +1,13 @@ -import { SignupForm } from "@/components/auth/signup-form"; +"use client" + +import { Suspense } from "react" +import { SignupForm } from "@/components/auth/signup-form" +import { SocialLoginButtons } from "@/components/auth/social-login-buttons" +import { Separator } from "@/components/ui/separator" export default function SignupPage() { return ( -
+

Create an account @@ -12,7 +17,22 @@ export default function SignupPage() {

+ + + + +
+
+ +
+
+ + or continue with email + +
+
+
- ); + ) } diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index 95449ba..bd928ce 100755 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -1,3 +1,58 @@ -import { handleAuth } from "@workos-inc/authkit-nextjs"; +import { NextRequest, NextResponse } from "next/server" +import { getWorkOSClient } from "@/lib/workos-client" +import { ensureUserExists } from "@/lib/auth" +import { SESSION_COOKIE } from "@/lib/session" -export const GET = handleAuth(); +export async function GET(request: NextRequest) { + const code = request.nextUrl.searchParams.get("code") + const state = request.nextUrl.searchParams.get("state") + + if (!code) { + return NextResponse.redirect( + new URL("/login?error=missing_code", request.url) + ) + } + + try { + const workos = getWorkOSClient() + if (!workos) { + return NextResponse.redirect( + new URL("/dashboard", request.url) + ) + } + + const result = + await workos.userManagement.authenticateWithCode({ + code, + clientId: process.env.WORKOS_CLIENT_ID!, + }) + + await ensureUserExists({ + id: result.user.id, + email: result.user.email, + firstName: result.user.firstName, + lastName: result.user.lastName, + profilePictureUrl: result.user.profilePictureUrl, + }) + + const redirectTo = state || "/dashboard" + const response = NextResponse.redirect( + new URL(redirectTo, request.url) + ) + + response.cookies.set(SESSION_COOKIE, result.accessToken, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 7, + }) + + return response + } catch (error) { + console.error("OAuth callback error:", error) + return NextResponse.redirect( + new URL("/login?error=auth_failed", request.url) + ) + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 3f9fd7c..8ac4d62 100755 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,91 +1,110 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client"; +import { NextRequest, NextResponse } from "next/server" +import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client" +import { ensureUserExists } from "@/lib/auth" +import { SESSION_COOKIE } from "@/lib/session" export async function POST(request: NextRequest) { try { - const workos = getWorkOSClient(); + const workos = getWorkOSClient() const body = (await request.json()) as { type: string email: string password?: string code?: string - }; - const { type, email, password, code } = body; + } + const { type, email, password, code } = body if (!workos) { return NextResponse.json({ success: true, redirectUrl: "/dashboard", devMode: true, - }); + }) } if (type === "password") { - const result = await workos.userManagement.authenticateWithPassword({ - email, - password: password!, - clientId: process.env.WORKOS_CLIENT_ID!, - }); + const result = + await workos.userManagement.authenticateWithPassword({ + email, + password: password!, + clientId: process.env.WORKOS_CLIENT_ID!, + }) + + await ensureUserExists({ + id: result.user.id, + email: result.user.email, + firstName: result.user.firstName, + lastName: result.user.lastName, + profilePictureUrl: result.user.profilePictureUrl, + }) const response = NextResponse.json({ success: true, redirectUrl: "/dashboard", - }); + }) - response.cookies.set("wos-session", result.accessToken, { + response.cookies.set(SESSION_COOKIE, result.accessToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 7, - }); + }) - return response; + return response } if (type === "passwordless_send") { - const magicAuth = await workos.userManagement.createMagicAuth({ - email, - }); + const magicAuth = + await workos.userManagement.createMagicAuth({ email }) return NextResponse.json({ success: true, magicAuthId: magicAuth.id, - }); + }) } if (type === "passwordless_verify") { - const result = await workos.userManagement.authenticateWithMagicAuth({ - code: code!, - email, - clientId: process.env.WORKOS_CLIENT_ID!, - }); + const result = + await workos.userManagement.authenticateWithMagicAuth({ + code: code!, + email, + clientId: process.env.WORKOS_CLIENT_ID!, + }) + + await ensureUserExists({ + id: result.user.id, + email: result.user.email, + firstName: result.user.firstName, + lastName: result.user.lastName, + profilePictureUrl: result.user.profilePictureUrl, + }) const response = NextResponse.json({ success: true, redirectUrl: "/dashboard", - }); + }) - response.cookies.set("wos-session", result.accessToken, { + response.cookies.set(SESSION_COOKIE, result.accessToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 7, - }); + }) - return response; + return response } return NextResponse.json( { success: false, error: "Invalid login type" }, { status: 400 } - ); + ) } catch (error) { - console.error("Login error:", error); + console.error("Login error:", error) return NextResponse.json( { success: false, error: mapWorkOSError(error) }, { status: 500 } - ); + ) } } diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts new file mode 100755 index 0000000..3f8908d --- /dev/null +++ b/src/app/api/auth/sso/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server" +import { getWorkOSClient } from "@/lib/workos-client" + +const VALID_PROVIDERS = [ + "GoogleOAuth", + "MicrosoftOAuth", + "GitHubOAuth", + "AppleOAuth", +] as const + +type Provider = (typeof VALID_PROVIDERS)[number] + +export async function GET(request: NextRequest) { + const provider = request.nextUrl.searchParams.get("provider") + const from = request.nextUrl.searchParams.get("from") + + if ( + !provider || + !VALID_PROVIDERS.includes(provider as Provider) + ) { + return NextResponse.redirect( + new URL("/login?error=invalid_provider", request.url) + ) + } + + const workos = getWorkOSClient() + if (!workos) { + return NextResponse.redirect( + new URL("/dashboard", request.url) + ) + } + + const authorizationUrl = + workos.userManagement.getAuthorizationUrl({ + provider: provider as Provider, + clientId: process.env.WORKOS_CLIENT_ID!, + redirectUri: process.env.WORKOS_REDIRECT_URI!, + state: from || "/dashboard", + }) + + return NextResponse.redirect(authorizationUrl) +} diff --git a/src/components/auth/social-login-buttons.tsx b/src/components/auth/social-login-buttons.tsx new file mode 100755 index 0000000..5497b27 --- /dev/null +++ b/src/components/auth/social-login-buttons.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { + IconBrandGoogle, + IconBrandWindows, + IconBrandGithub, + IconBrandApple, +} from "@tabler/icons-react" + +const providers = [ + { id: "GoogleOAuth", label: "Google", icon: IconBrandGoogle }, + { + id: "MicrosoftOAuth", + label: "Microsoft", + icon: IconBrandWindows, + }, + { id: "GitHubOAuth", label: "GitHub", icon: IconBrandGithub }, + { id: "AppleOAuth", label: "Apple", icon: IconBrandApple }, +] as const + +export function SocialLoginButtons() { + const searchParams = useSearchParams() + const from = searchParams.get("from") + + const handleSSOLogin = (provider: string) => { + const params = new URLSearchParams({ provider }) + if (from) params.set("from", from) + window.location.href = `/api/auth/sso?${params}` + } + + return ( +
+ {providers.map(({ id, label, icon: Icon }) => ( + + ))} +
+ ) +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b5420f4..50cec1a 100755 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,9 +1,11 @@ -import { withAuth, signOut } from "@workos-inc/authkit-nextjs" +import { cookies } from "next/headers" +import { redirect } from "next/navigation" import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { users } from "@/db/schema" import type { User } from "@/db/schema" import { eq } from "drizzle-orm" +import { SESSION_COOKIE, decodeJwtPayload } from "@/lib/session" export type AuthUser = { id: string @@ -21,14 +23,12 @@ export type AuthUser = { export async function getCurrentUser(): Promise { try { - // check if workos is configured const isWorkOSConfigured = process.env.WORKOS_API_KEY && process.env.WORKOS_CLIENT_ID && !process.env.WORKOS_API_KEY.includes("placeholder") if (!isWorkOSConfigured) { - // return mock user for development return { id: "dev-user-1", email: "dev@compass.io", @@ -44,34 +44,31 @@ export async function getCurrentUser(): Promise { } } - const session = await withAuth() - if (!session || !session.user) return null + const cookieStore = await cookies() + const token = cookieStore.get(SESSION_COOKIE)?.value + if (!token) return null - const workosUser = session.user + const payload = decodeJwtPayload(token) + if (!payload?.sub) return null + const userId = payload.sub as string const { env } = await getCloudflareContext() if (!env?.DB) return null const db = getDb(env.DB) - - // check if user exists in our database - let dbUser = await db + const dbUser = await db .select() .from(users) - .where(eq(users.id, workosUser.id)) + .where(eq(users.id, userId)) .get() - // if user doesn't exist, create them with default role - if (!dbUser) { - dbUser = await ensureUserExists(workosUser) - } + if (!dbUser) return null - // update last login timestamp const now = new Date().toISOString() await db .update(users) .set({ lastLoginAt: now }) - .where(eq(users.id, workosUser.id)) + .where(eq(users.id, userId)) .run() return { @@ -93,7 +90,7 @@ export async function getCurrentUser(): Promise { } } -async function ensureUserExists(workosUser: { +export async function ensureUserExists(workosUser: { id: string email: string firstName?: string | null @@ -101,13 +98,19 @@ async function ensureUserExists(workosUser: { profilePictureUrl?: string | null }): Promise { const { env } = await getCloudflareContext() - if (!env?.DB) { - throw new Error("Database not available") - } + if (!env?.DB) throw new Error("Database not available") const db = getDb(env.DB) - const now = new Date().toISOString() + const existing = await db + .select() + .from(users) + .where(eq(users.id, workosUser.id)) + .get() + + if (existing) return existing + + const now = new Date().toISOString() const newUser = { id: workosUser.id, email: workosUser.email, @@ -118,7 +121,7 @@ async function ensureUserExists(workosUser: { ? `${workosUser.firstName} ${workosUser.lastName}` : workosUser.email.split("@")[0], avatarUrl: workosUser.profilePictureUrl ?? null, - role: "office", // default role + role: "office", isActive: true, lastLoginAt: now, createdAt: now, @@ -126,37 +129,21 @@ async function ensureUserExists(workosUser: { } await db.insert(users).values(newUser).run() - return newUser as User } export async function handleSignOut() { - await signOut() + const cookieStore = await cookies() + cookieStore.delete(SESSION_COOKIE) + redirect("/login") } export async function requireAuth(): Promise { const user = await getCurrentUser() - if (!user) { - throw new Error("Unauthorized") - } + if (!user) throw new Error("Unauthorized") return user } export async function requireEmailVerified(): Promise { - const user = await requireAuth() - - // check verification status - const isWorkOSConfigured = - process.env.WORKOS_API_KEY && - process.env.WORKOS_CLIENT_ID && - !process.env.WORKOS_API_KEY.includes("placeholder") - - if (isWorkOSConfigured) { - const session = await withAuth() - if (session?.user && !session.user.emailVerified) { - throw new Error("Email not verified") - } - } - - return user + return requireAuth() } diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100755 index 0000000..362ebd3 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,24 @@ +export const SESSION_COOKIE = "wos-session" + +export function decodeJwtPayload( + token: string +): Record | null { + try { + const parts = token.split(".") + if (parts.length !== 3) return null + const base64 = parts[1] + .replace(/-/g, "+") + .replace(/_/g, "/") + const padded = + base64 + "=".repeat((4 - (base64.length % 4)) % 4) + return JSON.parse(atob(padded)) + } catch { + return null + } +} + +export function isTokenExpired(token: string): boolean { + const payload = decodeJwtPayload(token) + if (!payload?.exp) return true + return (payload.exp as number) * 1000 < Date.now() +} diff --git a/src/middleware.ts b/src/middleware.ts index 6d8e5aa..b928306 100755 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,22 +1,19 @@ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; -import { withAuth } from "@workos-inc/authkit-nextjs"; +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import { SESSION_COOKIE, isTokenExpired } from "@/lib/session" const isWorkOSConfigured = process.env.WORKOS_API_KEY && process.env.WORKOS_CLIENT_ID && - process.env.WORKOS_COOKIE_PASSWORD && - !process.env.WORKOS_API_KEY.includes("placeholder"); + !process.env.WORKOS_API_KEY.includes("placeholder") export default async function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; + const { pathname } = request.nextUrl - // bypass auth in dev mode if (!isWorkOSConfigured) { - return NextResponse.next(); + return NextResponse.next() } - // public routes (no auth required) const publicRoutes = [ "/login", "/signup", @@ -24,40 +21,37 @@ export default async function middleware(request: NextRequest) { "/verify-email", "/invite", "/api/auth", - ]; + "/callback", + ] - if (publicRoutes.some((route) => pathname.startsWith(route))) { - return NextResponse.next(); + if ( + publicRoutes.some((route) => pathname.startsWith(route)) + ) { + return NextResponse.next() } - // check session for protected routes - try { - const session = await withAuth(); - if (!session || !session.user) { - const loginUrl = new URL("/login", request.url); - loginUrl.searchParams.set("from", pathname); - return NextResponse.redirect(loginUrl); - } - } catch { - const loginUrl = new URL("/login", request.url); - loginUrl.searchParams.set("from", pathname); - return NextResponse.redirect(loginUrl); + const token = request.cookies.get(SESSION_COOKIE)?.value + + if (!token || isTokenExpired(token)) { + const loginUrl = new URL("/login", request.url) + loginUrl.searchParams.set("from", pathname) + const response = NextResponse.redirect(loginUrl) + if (token) response.cookies.delete(SESSION_COOKIE) + return response } - // add security headers - const response = NextResponse.next(); - response.headers.set("X-Frame-Options", "DENY"); - response.headers.set("X-Content-Type-Options", "nosniff"); + const response = NextResponse.next() + response.headers.set("X-Frame-Options", "DENY") + response.headers.set("X-Content-Type-Options", "nosniff") response.headers.set( "Strict-Transport-Security", "max-age=31536000; includeSubDomains" - ); - - return response; + ) + return response } export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], -}; +}