first ai implementation, oidc done

This commit is contained in:
overcuriousity 2025-07-16 19:43:18 +02:00
parent 1573557164
commit 32269b489c
14 changed files with 943 additions and 44 deletions

1
.astro/types.d.ts vendored
View File

@ -1 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

2
.gitignore vendored
View File

@ -4,6 +4,8 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
# Build output
_site/
dist/

View File

@ -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
}
});

82
package-lock.json generated
View File

@ -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"
}

View File

@ -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"

293
src/pages/api/ai/query.ts Normal file
View File

@ -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<string, { count: number; resetTime: number }>();
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' }
});
}
};

View File

@ -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' }
});
}
};

View File

@ -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 });
}
};

View File

@ -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' }
});
}
};

View File

@ -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' }
});
}
};

View File

@ -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' }
});
}
};

View File

@ -0,0 +1,54 @@
---
// Since server-side URL parameters aren't working,
// we'll handle this client-side and POST to the API
---
<html>
<head>
<title>Processing Authentication...</title>
</head>
<body>
<div style="text-align: center; padding: 4rem; font-family: sans-serif;">
<h2>Processing authentication...</h2>
<p>Please wait while we complete your login.</p>
</div>
<script>
// Get URL parameters from client-side
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
console.log('Client-side callback params:', { code: !!code, state: !!state, error });
if (error) {
window.location.href = '/?auth=error';
} else if (code && state) {
// Send the parameters to our API endpoint
fetch('/api/auth/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state })
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = data.redirectTo || '/';
} else {
window.location.href = '/?auth=error';
}
})
.catch(error => {
console.error('Authentication processing failed:', error);
window.location.href = '/?auth=error';
});
} else {
console.error('Missing code or state parameters');
window.location.href = '/?auth=error';
}
</script>
</body>
</html>

176
src/utils/auth.ts Normal file
View File

@ -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<string> {
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<SessionData | null> {
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<any> {
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<any> {
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) : '');
}

40
src/utils/serverAuth.ts Normal file
View File

@ -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<AuthContext> {
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;
}