consolidation and unification

This commit is contained in:
overcuriousity 2025-07-24 13:46:50 +02:00
parent 72bcc04309
commit f92219f61f
9 changed files with 200 additions and 320 deletions

View File

@ -16,6 +16,7 @@
"dotenv": "^16.4.5",
"jose": "^5.2.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.25.76"
},
"devDependencies": {

View File

@ -1,102 +0,0 @@
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 {
if (process.env.NODE_ENV === 'development') {
console.log('Auth callback processing...');
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

@ -28,7 +28,7 @@ export const GET: APIRoute = async ({ url, redirect }) => {
}
});
} catch (error) {
logAuthEvent('Login failed', { error: error.message });
logAuthEvent('Login failed', { error: error instanceof Error ? error.message : 'Unknown error' });
return new Response('Authentication error', { status: 500 });
}
};

View File

@ -1,99 +1,61 @@
// src/pages/api/auth/process.ts - Fixed Email Support
import type { APIRoute } from 'astro';
import { parse } from 'cookie';
import {
verifyAuthState,
exchangeCodeForTokens,
getUserInfo,
createSession,
createSessionCookie,
createSessionWithCookie,
logAuthEvent,
getUserEmail
createBadRequestResponse,
createSuccessResponse
} 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);
// Parse request body
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' }
});
return createBadRequestResponse('Invalid 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' }
});
return createBadRequestResponse('Missing required parameters');
}
// 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' }
});
// CONSOLIDATED: Single function call replaces 15+ lines of duplicated state verification
const stateVerification = verifyAuthState(request, state);
if (!stateVerification.isValid || !stateVerification.stateData) {
return createBadRequestResponse(stateVerification.error || 'Invalid state parameter');
}
// Exchange code for tokens
console.log('Exchanging code for tokens...');
// Exchange code for tokens and get user info
const tokens = await exchangeCodeForTokens(code);
// Get user info
console.log('Getting user info...');
const userInfo = await getUserInfo(tokens.access_token);
// Extract user details
const userId = userInfo.sub || userInfo.preferred_username || 'unknown';
const userEmail = getUserEmail(userInfo);
// Create session with email
const sessionToken = await createSession(userId, userEmail);
const sessionCookie = createSessionCookie(sessionToken);
// CONSOLIDATED: Single function call replaces 10+ lines of session creation
const sessionResult = await createSessionWithCookie(userInfo);
logAuthEvent('Authentication successful', {
userId: userId,
email: userEmail
userId: sessionResult.userId,
email: sessionResult.userEmail
});
// Clear auth state cookie
const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
const returnTo = storedStateData.returnTo || '/';
// Build response with cookies
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Set-Cookie', sessionCookie);
headers.append('Set-Cookie', clearStateCookie);
headers.append('Set-Cookie', sessionResult.sessionCookie);
headers.append('Set-Cookie', sessionResult.clearStateCookie);
return new Response(JSON.stringify({
success: true,
redirectTo: returnTo
redirectTo: stateVerification.stateData.returnTo
}), {
status: 200,
headers: headers
@ -101,10 +63,9 @@ export const POST: APIRoute = async ({ request }) => {
} catch (error) {
console.error('Authentication processing failed:', error);
logAuthEvent('Authentication processing failed', { error: error instanceof Error ? error.message : 'Unknown error' });
return new Response(JSON.stringify({ success: false }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
logAuthEvent('Authentication processing failed', {
error: error instanceof Error ? error.message : 'Unknown error'
});
return createBadRequestResponse('Authentication processing failed');
}
};

View File

@ -1,56 +1,24 @@
// src/pages/api/auth/status.ts
import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
import { withAPIAuth, createAPIResponse } from '../../../utils/auth.js';
export const prerender = false;
export const GET: APIRoute = async ({ request }) => {
try {
// Check if authentication is required
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
// CONSOLIDATED: Single function call replaces 35+ lines
const authResult = await withAPIAuth(request);
if (!authRequired) {
// If authentication is not required, always return authenticated
return new Response(JSON.stringify({
authenticated: true,
authRequired: false
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({
authenticated: false,
authRequired: true
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
const session = await verifySession(sessionToken);
return new Response(JSON.stringify({
authenticated: session !== null,
authRequired: true,
expires: session?.exp ? new Date(session.exp * 1000).toISOString() : null
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
return createAPIResponse({
authenticated: authResult.authenticated,
authRequired: authResult.authRequired,
expires: authResult.session?.exp ? new Date(authResult.session.exp * 1000).toISOString() : null
});
} catch (error) {
return new Response(JSON.stringify({
return createAPIResponse({
authenticated: false,
authRequired: process.env.AUTHENTICATION_NECESSARY !== 'false',
error: 'Session verification failed'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@ -1,6 +1,6 @@
// src/pages/api/contribute/knowledgebase.ts
import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
import { withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js';
import { GitContributionManager } from '../../../utils/gitContributions.js';
import { z } from 'zod';
@ -270,26 +270,13 @@ ${data.article.uploadedFiles.map((file: any) => `- ${file.name} (${file.url})`).
export const POST: APIRoute = async ({ request }) => {
try {
// Check authentication
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
if (authRequired) {
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) {
return createAuthErrorResponse('Authentication required');
}
const session = await verifySession(sessionToken);
if (!session) {
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const userEmail = authResult.session?.email || 'anonymous@example.com';
const userEmail = session.email;
// Rate limiting
if (!checkRateLimit(userEmail)) {
@ -383,14 +370,6 @@ export const POST: APIRoute = async ({ request }) => {
headers: { 'Content-Type': 'application/json' }
});
}
} else {
return new Response(JSON.stringify({
error: 'Authentication is disabled'
}), {
status: 501,
headers: { 'Content-Type': 'application/json' }
});
}
} catch (error) {
console.error('Knowledgebase contribution API error:', error);

View File

@ -1,6 +1,6 @@
// src/pages/api/contribute/tool.ts
import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
import { withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js';
import { GitContributionManager, type ContributionData } from '../../../utils/gitContributions.js';
import { z } from 'zod';
@ -189,14 +189,8 @@ async function validateToolData(tool: any, action: 'add' | 'edit'): Promise<{ va
export const POST: APIRoute = async ({ request }) => {
try {
// Check if authentication is required
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
let userId = 'anonymous';
let userEmail = 'anonymous@example.com';
if (authRequired) {
// Authentication check
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) {
return new Response(JSON.stringify({
success: false,
error: 'Authentication required'
@ -206,21 +200,9 @@ export const POST: APIRoute = async ({ request }) => {
});
}
const session = await verifySession(sessionToken);
if (!session) {
return new Response(JSON.stringify({
success: false,
error: 'Invalid session'
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const userId = authResult.session?.userId || 'anonymous';
const userEmail = authResult.session?.email || 'anonymous@example.com';
userId = session.userId;
// In a real implementation, you might want to fetch user email from session or OIDC
userEmail = `${userId}@cc24.dev`;
}
// Rate limiting
if (!checkRateLimit(userId)) {

View File

@ -1,6 +1,6 @@
// src/pages/api/upload/media.ts
import type { APIRoute } from 'astro';
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
import { getSessionFromRequest, verifySession, withAPIAuth, createAuthErrorResponse } from '../../../utils/auth.js';
import { NextcloudUploader, isNextcloudConfigured } from '../../../utils/nextcloud.js';
import { promises as fs } from 'fs';
import path from 'path';
@ -169,28 +169,13 @@ async function uploadToNextcloud(file: File, category: string): Promise<UploadRe
export const POST: APIRoute = async ({ request }) => {
try {
// Check authentication
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
let userEmail = 'anonymous';
if (authRequired) {
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return new Response(JSON.stringify({ error: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) {
return createAuthErrorResponse('Authentication required');
}
const session = await verifySession(sessionToken);
if (!session) {
return new Response(JSON.stringify({ error: 'Invalid session' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const userEmail = authResult.session?.email || 'anonymous';
userEmail = session.email;
}
// Rate limiting
if (!checkUploadRateLimit(userEmail)) {
@ -279,29 +264,15 @@ export const POST: APIRoute = async ({ request }) => {
export const GET: APIRoute = async ({ request }) => {
try {
// Check authentication
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
if (authRequired) {
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' }
});
}
const authResult = await withAPIAuth(request);
if (authResult.authRequired && !authResult.authenticated) {
return createAuthErrorResponse('Authentication required');
}
// Return upload configuration and status
const nextcloudConfigured = isNextcloudConfigured();
// Check local upload directory
let localStorageAvailable = false;
try {

View File

@ -3,6 +3,7 @@ import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import { serialize, parse } from 'cookie';
import { config } from 'dotenv';
import type { AstroGlobal } from 'astro';
import jwt from 'jsonwebtoken';
// Load environment variables
config();
@ -31,12 +32,25 @@ export interface SessionData {
}
export interface UserInfo {
sub?: string;
sub: string;
preferred_username?: string;
email?: string;
name?: string;
}
export interface AuthContext {
authenticated: boolean;
session: SessionData | null;
userEmail: string;
userId: string;
}
export interface AuthStateData {
state: string;
returnTo: string;
}
// Create a signed JWT session token with email
export async function createSession(userId: string, email: string): Promise<string> {
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION;
@ -97,7 +111,7 @@ export function createSessionCookie(token: string): string {
return serialize('session', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'strict', // More secure than 'lax'
sameSite: 'lax',
maxAge: SESSION_DURATION,
path: '/'
});
@ -199,13 +213,91 @@ export function getUserEmail(userInfo: UserInfo): string {
`${userInfo.preferred_username || userInfo.sub || 'unknown'}@cc24.dev`;
}
// === CONSOLIDATION: Server-side Auth Helpers ===
/**
* CONSOLIDATED: Parse and validate auth state from cookies
* Replaces duplicated cookie parsing in callback.ts and process.ts
*/
export function parseAuthState(request: Request): {
isValid: boolean;
stateData: AuthStateData | null;
error?: string
} {
try {
const cookieHeader = request.headers.get('cookie');
const cookies = cookieHeader ? parse(cookieHeader) : {};
export interface AuthContext {
authenticated: boolean;
session: SessionData | null;
userEmail: string;
if (!cookies.auth_state) {
return { isValid: false, stateData: null, error: 'No auth state cookie' };
}
const stateData = JSON.parse(decodeURIComponent(cookies.auth_state));
if (!stateData.state || !stateData.returnTo) {
return { isValid: false, stateData: null, error: 'Invalid state data structure' };
}
return { isValid: true, stateData };
} catch (error) {
return { isValid: false, stateData: null, error: 'Failed to parse auth state' };
}
}
/**
* CONSOLIDATED: Verify state parameter against stored state
* Replaces duplicated verification logic in callback.ts and process.ts
*/
export function verifyAuthState(request: Request, receivedState: string): {
isValid: boolean;
stateData: AuthStateData | null;
error?: string;
} {
const { isValid, stateData, error } = parseAuthState(request);
if (!isValid || !stateData) {
logAuthEvent('State parsing failed', { error });
return { isValid: false, stateData: null, error };
}
if (stateData.state !== receivedState) {
logAuthEvent('State mismatch', {
received: receivedState,
stored: stateData.state
});
return {
isValid: false,
stateData: null,
error: 'State parameter mismatch'
};
}
return { isValid: true, stateData };
}
/**
* CONSOLIDATED: Create session with cookie headers
* Replaces duplicated session creation in callback.ts and process.ts
*/
export async function createSessionWithCookie(userInfo: UserInfo): Promise<{
sessionToken: string;
sessionCookie: string;
clearStateCookie: string;
userId: string;
userEmail: string;
}> {
const userId = userInfo.sub || userInfo.preferred_username || 'unknown';
const userEmail = getUserEmail(userInfo);
const sessionToken = await createSession(userId, userEmail);
const sessionCookie = createSessionCookie(sessionToken);
const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
return {
sessionToken,
sessionCookie,
clearStateCookie,
userId,
userEmail
};
}
/**
@ -255,37 +347,47 @@ export async function withAuth(Astro: AstroGlobal): Promise<AuthContext | Respon
/**
* CONSOLIDATED: Replace repeated auth patterns in API endpoints
* Usage: const authResult = await withAPIAuth(request);
* if (!authResult.authenticated) return createAuthErrorResponse();
* Enhanced version with better return structure
*/
export async function withAPIAuth(request: Request): Promise<{
authenticated: boolean;
userId: string;
session?: SessionData
session?: SessionData;
authRequired: boolean;
}> {
const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
if (!authRequired) {
return {
authenticated: true,
userId: 'anonymous'
userId: 'anonymous',
authRequired: false
};
}
const sessionToken = getSessionFromRequest(request);
if (!sessionToken) {
return { authenticated: false, userId: '' };
return {
authenticated: false,
userId: '',
authRequired: true
};
}
const session = await verifySession(sessionToken);
if (!session) {
return { authenticated: false, userId: '' };
return {
authenticated: false,
userId: '',
authRequired: true
};
}
return {
authenticated: true,
userId: session.userId,
session
session,
authRequired: true
};
}
@ -298,3 +400,21 @@ export function createAuthErrorResponse(message: string = 'Authentication requir
headers: { 'Content-Type': 'application/json' }
});
}
/**
* CONSOLIDATED: Create consistent API responses
*/
export function createAPIResponse(data: any, status: number = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
export function createBadRequestResponse(message: string = 'Bad request'): Response {
return createAPIResponse({ error: message }, 400);
}
export function createSuccessResponse(data: any = { success: true }): Response {
return createAPIResponse(data, 200);
}