Bladeren bron

auth endpoint

Bernt Christian Egeland 7 maanden geleden
bovenliggende
commit
dbab4f9af8

+ 10 - 0
src/app/(authenticated)/layout.tsx

@@ -1,4 +1,5 @@
 import { redirect } from "next/navigation";
+import { cookies } from "next/headers";
 import { AppSidebar } from "@/components/app-sidebar";
 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
 import { SearchCommand } from "@/features/search/Components/SearchCommand";
@@ -23,6 +24,15 @@ export default async function DashboardLayout({
   if (data.status === "unauthenticated") redirect("/auth/sign-in");
   if (data.status === "no-organization") redirect("/onboarding");
 
+  // Check for pending desktop auth request
+  const cookieStore = await cookies();
+  const desktopAuthCookie = cookieStore.get("desktop_auth_request");
+  if (desktopAuthCookie) {
+    const { codeChallenge, state } = JSON.parse(desktopAuthCookie.value);
+    cookieStore.delete("desktop_auth_request");
+    redirect(`/auth/desktop?code_challenge=${codeChallenge}&state=${state}`);
+  }
+
   // Check email verification requirement (super admins bypass this)
   if (!data.isSuperAdmin && !data.emailVerified) {
     const verificationSetting = await db.systemSetting.findUnique({

+ 98 - 0
src/app/(public)/auth/desktop/desktop-auth-prompt.tsx

@@ -0,0 +1,98 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardFooter,
+  CardHeader,
+  CardTitle,
+} from '@/components/ui/card'
+
+interface DesktopAuthPromptProps {
+  userName: string
+  userEmail: string
+  codeChallenge: string
+  state: string
+}
+
+export function DesktopAuthPrompt({
+  userName,
+  userEmail,
+  codeChallenge,
+  state,
+}: DesktopAuthPromptProps) {
+  const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle')
+
+  async function handleAuthorize() {
+    setStatus('loading')
+    try {
+      const res = await fetch('/api/desktop/authorize', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ codeChallenge, state }),
+      })
+
+      if (!res.ok) {
+        setStatus('error')
+        return
+      }
+
+      const data = await res.json()
+      window.location.href = data.redirect_uri
+      setStatus('done')
+    } catch {
+      setStatus('error')
+    }
+  }
+
+  if (status === 'done') {
+    return (
+      <Card className="w-full max-w-md">
+        <CardHeader>
+          <CardTitle>Authorization Complete</CardTitle>
+          <CardDescription>
+            You can close this browser window and return to TorqVoice Desktop.
+          </CardDescription>
+        </CardHeader>
+      </Card>
+    )
+  }
+
+  return (
+    <Card className="w-full max-w-md">
+      <CardHeader>
+        <CardTitle>Authorize TorqVoice Desktop</CardTitle>
+        <CardDescription>
+          The TorqVoice desktop app is requesting access to your account.
+        </CardDescription>
+      </CardHeader>
+      <CardContent>
+        <div className="text-sm">
+          <p className="text-muted-foreground">Signed in as</p>
+          <p className="font-medium">{userName}</p>
+          <p className="text-muted-foreground">{userEmail}</p>
+        </div>
+      </CardContent>
+      <CardFooter className="flex gap-2 justify-end">
+        <Button
+          variant="outline"
+          onClick={() => window.history.back()}
+          disabled={status === 'loading'}
+        >
+          Cancel
+        </Button>
+        <Button onClick={handleAuthorize} disabled={status === 'loading'}>
+          {status === 'loading' ? 'Authorizing...' : 'Authorize'}
+        </Button>
+      </CardFooter>
+      {status === 'error' && (
+        <CardContent>
+          <p className="text-sm text-destructive">Authorization failed. Please try again.</p>
+        </CardContent>
+      )}
+    </Card>
+  )
+}

+ 49 - 0
src/app/(public)/auth/desktop/page.tsx

@@ -0,0 +1,49 @@
+import { redirect } from 'next/navigation'
+import { auth } from '@/lib/auth'
+import { headers, cookies } from 'next/headers'
+import { DesktopAuthPrompt } from './desktop-auth-prompt'
+
+export const dynamic = 'force-dynamic'
+
+export default async function DesktopAuthPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ code_challenge?: string; state?: string }>
+}) {
+  const params = await searchParams
+  const codeChallenge = params.code_challenge
+  const state = params.state
+
+  // Validate required params
+  if (!codeChallenge || codeChallenge.length < 43 || !state) {
+    return (
+      <div className="flex min-h-screen items-center justify-center">
+        <p className="text-muted-foreground">Invalid authorization request.</p>
+      </div>
+    )
+  }
+
+  const session = await auth.api.getSession({ headers: await headers() })
+
+  if (!session) {
+    // Store params in cookie and redirect to sign-in
+    const cookieStore = await cookies()
+    cookieStore.set('desktop_auth_request', JSON.stringify({ codeChallenge, state }), {
+      httpOnly: true,
+      path: '/',
+      maxAge: 600,
+    })
+    redirect('/auth/sign-in')
+  }
+
+  return (
+    <div className="flex min-h-screen items-center justify-center p-4">
+      <DesktopAuthPrompt
+        userName={session.user.name}
+        userEmail={session.user.email}
+        codeChallenge={codeChallenge}
+        state={state}
+      />
+    </div>
+  )
+}

+ 39 - 0
src/app/api/desktop/authorize/route.ts

@@ -0,0 +1,39 @@
+import { NextResponse } from 'next/server'
+import { headers } from 'next/headers'
+import { randomBytes } from 'crypto'
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+
+export async function POST(request: Request) {
+  const limited = rateLimit(request, { limit: 5 })
+  if (limited) return limited
+
+  const session = await auth.api.getSession({ headers: await headers() })
+  if (!session?.user?.id) {
+    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+  }
+
+  const { codeChallenge, state } = await request.json()
+  if (!codeChallenge || !state) {
+    return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 })
+  }
+
+  const code = randomBytes(32).toString('hex')
+
+  await db.verification.create({
+    data: {
+      identifier: `desktop-auth:${code}`,
+      value: JSON.stringify({
+        codeChallenge,
+        userId: session.user.id,
+        state,
+      }),
+      expiresAt: new Date(Date.now() + 5 * 60 * 1000),
+    },
+  })
+
+  return NextResponse.json({
+    redirect_uri: `torqvoice://auth/callback?code=${code}&state=${state}`,
+  })
+}

+ 69 - 0
src/app/api/desktop/token/route.ts

@@ -0,0 +1,69 @@
+import { NextResponse } from 'next/server'
+import { createHash, randomBytes } from 'crypto'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+
+export async function POST(request: Request) {
+  const limited = rateLimit(request, { limit: 10 })
+  if (limited) return limited
+
+  const { code, code_verifier } = await request.json()
+  if (!code || !code_verifier) {
+    return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 })
+  }
+
+  // Look up the authorization code
+  const verification = await db.verification.findUnique({
+    where: { identifier: `desktop-auth:${code}` },
+  })
+
+  if (!verification) {
+    return NextResponse.json({ error: 'Invalid authorization code' }, { status: 400 })
+  }
+
+  // Delete immediately (single-use)
+  await db.verification.delete({
+    where: { identifier: `desktop-auth:${code}` },
+  })
+
+  // Validate expiry
+  if (verification.expiresAt < new Date()) {
+    return NextResponse.json({ error: 'Authorization code expired' }, { status: 400 })
+  }
+
+  const { codeChallenge, userId, state } = JSON.parse(verification.value)
+
+  // PKCE validation: verify code_verifier matches the stored code_challenge
+  const hash = createHash('sha256').update(code_verifier).digest('base64url')
+  if (hash !== codeChallenge) {
+    return NextResponse.json({ error: 'Invalid code verifier' }, { status: 400 })
+  }
+
+  // Create a session token
+  const sessionToken = randomBytes(32).toString('hex')
+
+  await db.session.create({
+    data: {
+      token: sessionToken,
+      userId,
+      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+    },
+  })
+
+  // Update last login
+  await db.user.update({
+    where: { id: userId },
+    data: { lastLogin: new Date() },
+  })
+
+  // Fetch user data
+  const user = await db.user.findUnique({
+    where: { id: userId },
+    select: { id: true, name: true, email: true },
+  })
+
+  return NextResponse.json({
+    token: sessionToken,
+    user,
+  })
+}

+ 1 - 1
src/lib/auth.ts

@@ -11,7 +11,7 @@ const isProduction = baseURL?.startsWith('https://')
 export const auth = betterAuth({
   baseURL,
   basePath: '/api/public/auth',
-  trustedOrigins: baseURL ? [baseURL] : [],
+  trustedOrigins: [...(baseURL ? [baseURL] : []), 'http://tauri.localhost', 'tauri://localhost'],
   database: prismaAdapter(db, {
     provider: 'postgresql',
   }),