From 32269b489c62a695641d7f0459e25749e93a9d86 Mon Sep 17 00:00:00 2001 From: overcuriousity Date: Wed, 16 Jul 2025 19:43:18 +0200 Subject: [PATCH] first ai implementation, oidc done --- .astro/types.d.ts | 1 + .gitignore | 2 + astro-config.mjs | 15 +- package-lock.json | 82 +++++---- package.json | 12 +- src/pages/api/ai/query.ts | 293 +++++++++++++++++++++++++++++++++ src/pages/api/auth/callback.ts | 100 +++++++++++ src/pages/api/auth/login.ts | 34 ++++ src/pages/api/auth/logout.ts | 38 +++++ src/pages/api/auth/process.ts | 104 ++++++++++++ src/pages/api/auth/status.ts | 36 ++++ src/pages/auth/callback.astro | 54 ++++++ src/utils/auth.ts | 176 ++++++++++++++++++++ src/utils/serverAuth.ts | 40 +++++ 14 files changed, 943 insertions(+), 44 deletions(-) create mode 100644 src/pages/api/ai/query.ts create mode 100644 src/pages/api/auth/callback.ts create mode 100644 src/pages/api/auth/login.ts create mode 100644 src/pages/api/auth/logout.ts create mode 100644 src/pages/api/auth/process.ts create mode 100644 src/pages/api/auth/status.ts create mode 100644 src/pages/auth/callback.astro create mode 100644 src/utils/auth.ts create mode 100644 src/utils/serverAuth.ts diff --git a/.astro/types.d.ts b/.astro/types.d.ts index f964fe0..03d7cc4 100644 --- a/.astro/types.d.ts +++ b/.astro/types.d.ts @@ -1 +1,2 @@ /// +/// \ No newline at end of file diff --git a/.gitignore b/.gitignore index f536954..444cf16 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +package-lock.json + # Build output _site/ dist/ diff --git a/astro-config.mjs b/astro-config.mjs index 4046a7b..907fdea 100644 --- a/astro-config.mjs +++ b/astro-config.mjs @@ -1,9 +1,13 @@ -// astro.config.mjs - Static deployment configuration +// astro.config.mjs - SSR configuration for authentication import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; export default defineConfig({ - // Static site generation - no adapter needed - output: 'static', + // Server-side rendering for authentication and API routes + output: 'server', + adapter: node({ + mode: 'standalone' + }), // Build configuration build: { @@ -14,10 +18,5 @@ export default defineConfig({ server: { port: 4321, host: true - }, - - // Ensure all pages are pre-rendered - experimental: { - prerender: true } }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 423f668..a63b571 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,19 @@ "name": "dfir-tools-hub", "version": "1.0.0", "dependencies": { + "@astrojs/node": "^9.3.0", "astro": "^5.3.0", + "cookie": "^0.6.0", + "dotenv": "^16.4.5", + "jose": "^5.2.0", "js-yaml": "^4.1.0" }, "devDependencies": { + "@types/cookie": "^0.6.0", "@types/js-yaml": "^4.0.9" }, "engines": { "node": ">=18.0.0" - }, - "optionalDependencies": { - "@astrojs/node": "^9.3.0" } }, "node_modules/@astrojs/compiler": { @@ -67,7 +69,6 @@ "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.3.0.tgz", "integrity": "sha512-IV8NzGStHAsKBz1ljxxD8PBhBfnw/BEx/PZfsncTNXg9D4kQtZbSy+Ak0LvDs+rPmK0VeXLNn0HAdWuHCVg8cw==", "license": "MIT", - "optional": true, "dependencies": { "@astrojs/internal-helpers": "0.6.1", "send": "^1.2.0", @@ -1374,6 +1375,13 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1679,6 +1687,15 @@ "sharp": "^0.33.3" } }, + "node_modules/astro/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1962,12 +1979,12 @@ "license": "ISC" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 0.6" } }, "node_modules/cookie-es": { @@ -2060,7 +2077,6 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.8" } @@ -2142,6 +2158,18 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dset": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", @@ -2155,8 +2183,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/emoji-regex": { "version": "10.4.0", @@ -2169,7 +2196,6 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.8" } @@ -2237,8 +2263,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "5.0.0", @@ -2266,7 +2291,6 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.6" } @@ -2344,7 +2368,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.8" } @@ -2612,7 +2635,6 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "license": "MIT", - "optional": true, "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -2629,7 +2651,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.8" } @@ -2648,8 +2669,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/iron-webcrypto": { "version": "1.2.1", @@ -2736,6 +2756,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -3602,7 +3631,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.6" } @@ -3612,7 +3640,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", - "optional": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -3738,7 +3765,6 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", - "optional": true, "dependencies": { "ee-first": "1.1.1" }, @@ -3946,7 +3972,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.6" } @@ -4240,7 +4265,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", - "optional": true, "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", @@ -4262,15 +4286,13 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/sharp": { "version": "0.33.5", @@ -4403,7 +4425,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.8" } @@ -4507,7 +4528,6 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", - "optional": true, "engines": { "node": ">=0.6" } diff --git a/package.json b/package.json index 51f98de..a886c00 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,15 @@ }, "dependencies": { "astro": "^5.3.0", - "js-yaml": "^4.1.0" + "@astrojs/node": "^9.3.0", + "js-yaml": "^4.1.0", + "jose": "^5.2.0", + "cookie": "^0.6.0", + "dotenv": "^16.4.5" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" - }, - "optionalDependencies": { - "@astrojs/node": "^9.3.0" + "@types/js-yaml": "^4.0.9", + "@types/cookie": "^0.6.0" }, "engines": { "node": ">=18.0.0" diff --git a/src/pages/api/ai/query.ts b/src/pages/api/ai/query.ts new file mode 100644 index 0000000..894153c --- /dev/null +++ b/src/pages/api/ai/query.ts @@ -0,0 +1,293 @@ +// src/pages/api/ai/query.ts +import type { APIRoute } from 'astro'; +import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; +import { promises as fs } from 'fs'; +import { load } from 'js-yaml'; +import path from 'path'; + + +export const prerender = false; + + +function getEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing environment variable: ${key}`); + } + return value; +} + +const AI_MODEL = getEnv('AI_MODEL'); +// Rate limiting store (in production, use Redis) +const rateLimitStore = new Map(); +const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute +const RATE_LIMIT_MAX = 10; // 10 requests per minute per user + +// Input validation and sanitization +function sanitizeInput(input: string): string { + // Remove potential prompt injection patterns + const dangerous = [ + /ignore\s+previous\s+instructions?/gi, + /new\s+instructions?:/gi, + /system\s*:/gi, + /assistant\s*:/gi, + /human\s*:/gi, + /<\s*\/?system\s*>/gi, + /```\s*system/gi, + ]; + + let sanitized = input.trim(); + dangerous.forEach(pattern => { + sanitized = sanitized.replace(pattern, '[FILTERED]'); + }); + + // Limit length + return sanitized.slice(0, 2000); +} + +// Strip markdown code blocks from AI response +function stripMarkdownJson(content: string): string { + // Remove ```json and ``` wrappers + return content + .replace(/^```json\s*/i, '') + .replace(/\s*```\s*$/, '') + .trim(); +} + +// Rate limiting check +function checkRateLimit(userId: string): boolean { + const now = Date.now(); + const userLimit = rateLimitStore.get(userId); + + if (!userLimit || now > userLimit.resetTime) { + rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); + return true; + } + + if (userLimit.count >= RATE_LIMIT_MAX) { + return false; + } + + userLimit.count++; + return true; +} + +// Load tools database +async function loadToolsDatabase() { + try { + const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml'); + const yamlContent = await fs.readFile(yamlPath, 'utf8'); + return load(yamlContent) as any; + } catch (error) { + console.error('Failed to load tools database:', error); + throw new Error('Database unavailable'); + } +} + +// Create system prompt +function createSystemPrompt(toolsData: any): string { + const toolsList = toolsData.tools.map((tool: any) => ({ + name: tool.name, + description: tool.description, + domains: tool.domains, + phases: tool.phases, + platforms: tool.platforms, + skillLevel: tool.skillLevel, + license: tool.license, + tags: tool.tags, + projectUrl: tool.projectUrl ? 'self-hosted' : 'external' + })); + + // Dynamically build phases list from configuration + const phasesDescription = toolsData.phases.map((phase: any) => + `- ${phase.id}: ${phase.name}` + ).join('\n'); + + // Dynamically build domains list from configuration + const domainsDescription = toolsData.domains.map((domain: any) => + `- ${domain.id}: ${domain.name}` + ).join('\n'); + + return `Du bist ein DFIR (Digital Forensics and Incident Response) Experte, der Ermittlern bei der Toolauswahl hilft. + +VERFÜGBARE TOOLS DATABASE: +${JSON.stringify(toolsList, null, 2)} + +UNTERSUCHUNGSPHASEN (NIST Framework): +${phasesDescription} + +FORENSISCHE DOMÄNEN: +${domainsDescription} + +PRIORITÄTEN: +1. Self-hosted Tools (projectUrl: "self-hosted") bevorzugen +2. Open Source Tools bevorzugen (license != "Proprietary") +3. Maximal 3 Tools pro Phase empfehlen +4. Deutsche Antworten für deutsche Anfragen, English for English queries + +ANTWORT-FORMAT (strict JSON): +{ + "scenario_analysis": "Detaillierte Analyse des Szenarios auf Deutsch/English", + "recommended_tools": [ + { + "name": "EXAKTER Name aus der Database", + "priority": "high|medium|low", + "phase": "data-collection|examination|analysis|reporting", + "justification": "Warum dieses Tool für dieses Szenario geeignet ist" + } + ], + "workflow_suggestion": "Vorgeschlagener Untersuchungsablauf", + "additional_notes": "Wichtige Überlegungen und Hinweise" +} + +Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des JSON.`; +} + +export const POST: APIRoute = async ({ request }) => { + try { + // Check if authentication is required + const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false'; + let userId = 'test-user'; + + if (authRequired) { + // Authentication check + const sessionToken = getSessionFromRequest(request); + if (!sessionToken) { + return new Response(JSON.stringify({ error: 'Authentication required' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + const session = await verifySession(sessionToken); + if (!session) { + return new Response(JSON.stringify({ error: 'Invalid session' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + userId = session.userId; + } + + // Rate limiting + if (!checkRateLimit(userId)) { + return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { + status: 429, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Parse request body + const body = await request.json(); + const { query } = body; + + if (!query || typeof query !== 'string') { + return new Response(JSON.stringify({ error: 'Query required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Sanitize input + const sanitizedQuery = sanitizeInput(query); + if (sanitizedQuery.includes('[FILTERED]')) { + return new Response(JSON.stringify({ error: 'Invalid input detected' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Load tools database + const toolsData = await loadToolsDatabase(); + + // Create AI request + const systemPrompt = createSystemPrompt(toolsData); + + const aiResponse = await fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.AI_API_KEY}` + }, + body: JSON.stringify({ + model: AI_MODEL, // or whatever model is available + messages: [ + { + role: 'system', + content: systemPrompt + }, + { + role: 'user', + content: sanitizedQuery + } + ], + max_tokens: 2000, + temperature: 0.3 + }) + }); + + if (!aiResponse.ok) { + console.error('AI API error:', await aiResponse.text()); + return new Response(JSON.stringify({ error: 'AI service unavailable' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + + const aiData = await aiResponse.json(); + const aiContent = aiData.choices?.[0]?.message?.content; + + if (!aiContent) { + return new Response(JSON.stringify({ error: 'No response from AI' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Parse AI JSON response + let recommendation; + try { + const cleanedContent = stripMarkdownJson(aiContent); + recommendation = JSON.parse(cleanedContent); + } catch (error) { + console.error('Failed to parse AI response:', aiContent); + return new Response(JSON.stringify({ error: 'Invalid AI response format' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Validate tool names against database + const validToolNames = new Set(toolsData.tools.map((t: any) => t.name)); + const validatedRecommendation = { + ...recommendation, + recommended_tools: recommendation.recommended_tools?.filter((tool: any) => { + if (!validToolNames.has(tool.name)) { + console.warn(`AI recommended unknown tool: ${tool.name}`); + return false; + } + return true; + }) || [] + }; + + // Log successful query + console.log(`[AI Query] User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}`); + + return new Response(JSON.stringify({ + success: true, + recommendation: validatedRecommendation, + query: sanitizedQuery + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error) { + console.error('AI query error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/callback.ts b/src/pages/api/auth/callback.ts new file mode 100644 index 0000000..604b364 --- /dev/null +++ b/src/pages/api/auth/callback.ts @@ -0,0 +1,100 @@ +import type { APIRoute } from 'astro'; +import { parse } from 'cookie'; +import { + exchangeCodeForTokens, + getUserInfo, + createSession, + createSessionCookie, + logAuthEvent +} from '../../../utils/auth.js'; + +export const GET: APIRoute = async ({ url, request }) => { + try { + // Debug: multiple ways to access URL parameters + console.log('Full URL:', url.toString()); + console.log('URL pathname:', url.pathname); + console.log('URL search:', url.search); + console.log('URL searchParams:', url.searchParams.toString()); + + // Try different ways to get parameters + const allParams = Object.fromEntries(url.searchParams.entries()); + console.log('SearchParams entries:', allParams); + + // Also try parsing manually from the search string + const manualParams = new URLSearchParams(url.search); + const manualEntries = Object.fromEntries(manualParams.entries()); + console.log('Manual URLSearchParams:', manualEntries); + + // Also check request URL + const requestUrl = new URL(request.url); + console.log('Request URL:', requestUrl.toString()); + const requestParams = Object.fromEntries(requestUrl.searchParams.entries()); + console.log('Request URL params:', requestParams); + + const code = url.searchParams.get('code') || requestUrl.searchParams.get('code'); + const state = url.searchParams.get('state') || requestUrl.searchParams.get('state'); + const error = url.searchParams.get('error') || requestUrl.searchParams.get('error'); + + console.log('Final extracted values:', { code: !!code, state: !!state, error }); + + // Handle OIDC errors + if (error) { + logAuthEvent('OIDC error', { error, description: url.searchParams.get('error_description') }); + return new Response(null, { + status: 302, + headers: { 'Location': '/?auth=error' } + }); + } + + if (!code || !state) { + logAuthEvent('Missing code or state parameter', { received: allParams }); + return new Response('Invalid callback parameters', { status: 400 }); + } + + // Verify state parameter + const cookieHeader = request.headers.get('cookie'); + const cookies = cookieHeader ? parse(cookieHeader) : {}; + const storedStateData = cookies.auth_state ? JSON.parse(decodeURIComponent(cookies.auth_state)) : null; + + if (!storedStateData || storedStateData.state !== state) { + logAuthEvent('State mismatch', { received: state, stored: storedStateData?.state }); + return new Response('Invalid state parameter', { status: 400 }); + } + + // Exchange code for tokens + const tokens = await exchangeCodeForTokens(code); + + // Get user info + const userInfo = await getUserInfo(tokens.access_token); + + // Create session + const sessionToken = await createSession(userInfo.sub || userInfo.preferred_username || 'unknown'); + const sessionCookie = createSessionCookie(sessionToken); + + logAuthEvent('Authentication successful', { + userId: userInfo.sub || userInfo.preferred_username, + email: userInfo.email + }); + + // Clear auth state cookie and redirect to intended destination + const returnTo = storedStateData.returnTo || '/'; + const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0'; + + const headers = new Headers(); + headers.append('Location', returnTo); + headers.append('Set-Cookie', sessionCookie); + headers.append('Set-Cookie', clearStateCookie); + + return new Response(null, { + status: 302, + headers: headers + }); + + } catch (error) { + logAuthEvent('Callback failed', { error: error.message }); + return new Response(null, { + status: 302, + headers: { 'Location': '/?auth=error' } + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts new file mode 100644 index 0000000..a6d102b --- /dev/null +++ b/src/pages/api/auth/login.ts @@ -0,0 +1,34 @@ +import type { APIRoute } from 'astro'; +import { generateAuthUrl, generateState, logAuthEvent } from '../../../utils/auth.js'; + +export const prerender = false; + +export const GET: APIRoute = async ({ url, redirect }) => { + try { + const state = generateState(); + const authUrl = generateAuthUrl(state); + + // Debug: log the generated URL + console.log('Generated auth URL:', authUrl); + + // Get the intended destination after login (if any) + const returnTo = url.searchParams.get('returnTo') || '/'; + + logAuthEvent('Login initiated', { returnTo, authUrl }); + + // Store state and returnTo in a cookie for the callback + const stateData = JSON.stringify({ state, returnTo }); + const stateCookie = `auth_state=${encodeURIComponent(stateData)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`; // 10 minutes + + return new Response(null, { + status: 302, + headers: { + 'Location': authUrl, + 'Set-Cookie': stateCookie + } + }); + } catch (error) { + logAuthEvent('Login failed', { error: error.message }); + return new Response('Authentication error', { status: 500 }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts new file mode 100644 index 0000000..689de14 --- /dev/null +++ b/src/pages/api/auth/logout.ts @@ -0,0 +1,38 @@ +import type { APIRoute } from 'astro'; +import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + try { + const sessionToken = getSessionFromRequest(request); + + if (!sessionToken) { + return new Response(JSON.stringify({ + authenticated: false + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + const session = await verifySession(sessionToken); + + return new Response(JSON.stringify({ + authenticated: session !== null, + expires: session?.exp ? new Date(session.exp * 1000).toISOString() : null + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error) { + return new Response(JSON.stringify({ + authenticated: false, + error: 'Session verification failed' + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/process.ts b/src/pages/api/auth/process.ts new file mode 100644 index 0000000..3459fef --- /dev/null +++ b/src/pages/api/auth/process.ts @@ -0,0 +1,104 @@ +import type { APIRoute } from 'astro'; +import { parse } from 'cookie'; +import { + exchangeCodeForTokens, + getUserInfo, + createSession, + createSessionCookie, + logAuthEvent +} from '../../../utils/auth.js'; + +// Mark as server-rendered +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + try { + // Check if there's a body to parse + const contentType = request.headers.get('content-type'); + console.log('Request content-type:', contentType); + + let body; + try { + body = await request.json(); + } catch (parseError) { + console.error('JSON parse error:', parseError); + return new Response(JSON.stringify({ success: false, error: 'Invalid JSON' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + const { code, state } = body || {}; + + console.log('Processing authentication:', { code: !!code, state: !!state }); + + if (!code || !state) { + logAuthEvent('Missing code or state parameter in process request'); + return new Response(JSON.stringify({ success: false }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Verify state parameter + const cookieHeader = request.headers.get('cookie'); + const cookies = cookieHeader ? parse(cookieHeader) : {}; + const storedStateData = cookies.auth_state ? JSON.parse(decodeURIComponent(cookies.auth_state)) : null; + + console.log('State verification:', { + received: state, + stored: storedStateData?.state, + match: storedStateData?.state === state + }); + + if (!storedStateData || storedStateData.state !== state) { + logAuthEvent('State mismatch', { received: state, stored: storedStateData?.state }); + return new Response(JSON.stringify({ success: false }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Exchange code for tokens + console.log('Exchanging code for tokens...'); + const tokens = await exchangeCodeForTokens(code); + + // Get user info + console.log('Getting user info...'); + const userInfo = await getUserInfo(tokens.access_token); + + // Create session + const sessionToken = await createSession(userInfo.sub || userInfo.preferred_username || 'unknown'); + const sessionCookie = createSessionCookie(sessionToken); + + logAuthEvent('Authentication successful', { + userId: userInfo.sub || userInfo.preferred_username, + email: userInfo.email + }); + + // Clear auth state cookie + const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0'; + const returnTo = storedStateData.returnTo || '/'; + + const headers = new Headers(); + headers.append('Content-Type', 'application/json'); + headers.append('Set-Cookie', sessionCookie); + headers.append('Set-Cookie', clearStateCookie); + + return new Response(JSON.stringify({ + success: true, + redirectTo: returnTo + }), { + status: 200, + headers: headers + }); + + } catch (error) { + console.error('Authentication processing failed:', error); + logAuthEvent('Authentication processing failed', { error: error.message }); + return new Response(JSON.stringify({ success: false }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; \ No newline at end of file diff --git a/src/pages/api/auth/status.ts b/src/pages/api/auth/status.ts new file mode 100644 index 0000000..a7dfb28 --- /dev/null +++ b/src/pages/api/auth/status.ts @@ -0,0 +1,36 @@ +import type { APIRoute } from 'astro'; +import { getSessionFromRequest, verifySession } from '../../../utils/auth.js'; + +export const GET: APIRoute = async ({ request }) => { + try { + const sessionToken = getSessionFromRequest(request); + + if (!sessionToken) { + return new Response(JSON.stringify({ + authenticated: false + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + const session = await verifySession(sessionToken); + + return new Response(JSON.stringify({ + authenticated: session !== null, + expires: session?.exp ? new Date(session.exp * 1000).toISOString() : null + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error) { + return new Response(JSON.stringify({ + authenticated: false, + error: 'Session verification failed' + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } +}; \ No newline at end of file diff --git a/src/pages/auth/callback.astro b/src/pages/auth/callback.astro new file mode 100644 index 0000000..97400c5 --- /dev/null +++ b/src/pages/auth/callback.astro @@ -0,0 +1,54 @@ +--- +// Since server-side URL parameters aren't working, +// we'll handle this client-side and POST to the API +--- + + + + Processing Authentication... + + +
+

Processing authentication...

+

Please wait while we complete your login.

+
+ + + + \ No newline at end of file diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..c9c28bd --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,176 @@ +import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; +import { serialize, parse } from 'cookie'; +import { config } from 'dotenv'; + +// Load environment variables +config(); + +// Environment variables - use runtime access for server-side +function getEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing environment variable: ${key}`); + } + return value; +} + +const SECRET_KEY = new TextEncoder().encode(getEnv('OIDC_CLIENT_SECRET')); +const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds + +export interface SessionData { + userId: string; + authenticated: boolean; + exp: number; +} + +// Create a signed JWT session token +export async function createSession(userId: string): Promise { + const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION; + + return await new SignJWT({ + userId, + authenticated: true, + exp + }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime(exp) + .sign(SECRET_KEY); +} + +// Verify and decode a session token +export async function verifySession(token: string): Promise { + try { + const { payload } = await jwtVerify(token, SECRET_KEY); + + // Validate payload structure and cast properly + if ( + typeof payload.userId === 'string' && + typeof payload.authenticated === 'boolean' && + typeof payload.exp === 'number' + ) { + return { + userId: payload.userId, + authenticated: payload.authenticated, + exp: payload.exp + }; + } + + return null; + } catch (error) { + console.log('Session verification failed:', error); + return null; + } +} + +// Get session from request cookies +export function getSessionFromRequest(request: Request): string | null { + const cookieHeader = request.headers.get('cookie'); + if (!cookieHeader) return null; + + const cookies = parse(cookieHeader); + return cookies.session || null; +} + +// Create session cookie +export function createSessionCookie(token: string): string { + const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); + const isSecure = publicBaseUrl.startsWith('https://'); + + return serialize('session', token, { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + maxAge: SESSION_DURATION, + path: '/' + }); +} + +// Clear session cookie +export function clearSessionCookie(): string { + const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); + const isSecure = publicBaseUrl.startsWith('https://'); + + return serialize('session', '', { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + maxAge: 0, + path: '/' + }); +} + +// Generate OIDC authorization URL +export function generateAuthUrl(state: string): string { + const oidcEndpoint = getEnv('OIDC_ENDPOINT'); + const clientId = getEnv('OIDC_CLIENT_ID'); + const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: `${publicBaseUrl}/auth/callback`, + scope: 'openid profile email', + state: state + }); + + return `${oidcEndpoint}/apps/oidc/authorize?${params.toString()}`; +} + +// Exchange authorization code for tokens +export async function exchangeCodeForTokens(code: string): Promise { + const oidcEndpoint = getEnv('OIDC_ENDPOINT'); + const clientId = getEnv('OIDC_CLIENT_ID'); + const clientSecret = getEnv('OIDC_CLIENT_SECRET'); + const publicBaseUrl = getEnv('PUBLIC_BASE_URL'); + + const response = await fetch(`${oidcEndpoint}/apps/oidc/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${btoa(`${clientId}:${clientSecret}`)}` + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + redirect_uri: `${publicBaseUrl}/auth/callback` + }) + }); + + if (!response.ok) { + const error = await response.text(); + console.error('Token exchange failed:', error); + throw new Error('Failed to exchange authorization code'); + } + + return await response.json(); +} + +// Get user info from OIDC provider +export async function getUserInfo(accessToken: string): Promise { + const oidcEndpoint = getEnv('OIDC_ENDPOINT'); + + const response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + const error = await response.text(); + console.error('Userinfo request failed:', error); + throw new Error('Failed to get user info'); + } + + return await response.json(); +} + +// Generate random state for CSRF protection +export function generateState(): string { + return crypto.randomUUID(); +} + +// Log authentication events for debugging +export function logAuthEvent(event: string, details?: any) { + const timestamp = new Date().toISOString(); + console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : ''); +} \ No newline at end of file diff --git a/src/utils/serverAuth.ts b/src/utils/serverAuth.ts new file mode 100644 index 0000000..09ce7d4 --- /dev/null +++ b/src/utils/serverAuth.ts @@ -0,0 +1,40 @@ +import type { AstroGlobal } from 'astro'; +import { getSessionFromRequest, verifySession, type SessionData } from './auth.js'; + +export interface AuthContext { + authenticated: boolean; + session: SessionData | null; +} + +// Check authentication status for server-side pages +export async function getAuthContext(Astro: AstroGlobal): Promise { + try { + const sessionToken = getSessionFromRequest(Astro.request); + + if (!sessionToken) { + return { authenticated: false, session: null }; + } + + const session = await verifySession(sessionToken); + + return { + authenticated: session !== null, + session + }; + } catch (error) { + console.error('Failed to get auth context:', error); + return { authenticated: false, session: null }; + } +} + +// Redirect to login if not authenticated +export function requireAuth(authContext: AuthContext, currentUrl: string): Response | null { + if (!authContext.authenticated) { + const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(currentUrl)}`; + return new Response(null, { + status: 302, + headers: { 'Location': loginUrl } + }); + } + return null; +} \ No newline at end of file