diff --git a/.env.example b/.env.example index bd84848..8224e14 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,14 @@ SAML_SP_CERT="-----BEGIN CERTIFICATE----- SAML_SP_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----" + +# Authorization Configuration +# Global access via eduPersonEntitlement (comma-separated list) +# Users with any of these entitlements get full access to all applications +CHURRO_GLOBAL_ENTITLEMENTS=stanford:staff + +# Per-application access via SUNet ID mappings +# Format: uuid1:uid1,uid2;uuid2:uid3,uid4 +# Each section (separated by ;) maps an application UUID to authorized SUNet IDs +# Example: CHURRO_APP_ACCESS=app-uuid-1:jdoe,jsmith;app-uuid-2:jdoe +CHURRO_APP_ACCESS= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9a68617..51bab96 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -102,27 +102,27 @@ 4. Stanford returns signed+encrypted assertion to `/api/saml/acs` 5. `saml.validatePostResponseAsync()` verifies signature and decrypts 6. Extract attributes via OID mappings (e.g., `urn:oid:0.9.2342.19200300.100.1.1` = SUNet ID) -7. Generate JWT token from user profile using `jose` library (`lib/jwt-auth.ts`) -8. Set JWT in HTTP-only cookie (`churro-auth-token`) with 24-hour expiration +7. Create encrypted session from user profile using `iron-session` library (`lib/session-auth.ts`) +8. Set encrypted HTTP-only session cookie (`churro-auth-token`) with 24-hour expiration 9. Redirect to application (no user data in URL params - security best practice) -**JWT Cookie Authentication** (`lib/session-auth.ts`): +**Iron Session Authentication** (`lib/session-auth.ts`): - Uses `iron-session` library for encrypted session management with better security than signed JWTs - Secret from `SESSION_SECRET` environment variable (required, no default) -- Cookie options: `httpOnly: true`, `secure: true` (production), `sameSite: 'lax'` -- Token expires in 24 hours +- Cookie options: `httpOnly: true`, `secure: true` (production), `sameSite: 'strict'` +- Session expires in 24 hours - Helper functions: `createSession()`, `verifySession()`, `getSessionCookieName()` **Middleware Protection** (`middleware.ts`): -- Checks JWT cookie on protected routes (e.g., `/protected/*`) -- Verifies token validity and redirects to `/api/saml/login` if invalid +- Checks encrypted session cookie on protected routes (e.g., `/protected/*`) +- Verifies session validity and redirects to `/api/saml/login` if invalid - Adds user info to request headers (`x-user-id`, `x-user-sunetid`, `x-user-email`) - Non-protected routes pass through without checks **Client-Side Auth Checking**: -- Use `/api/auth/status` to check authentication (reads HTTP-only cookie server-side) +- Use `/api/auth/status` to check authentication (reads encrypted session cookie server-side) - Returns `{ authenticated: boolean, user: {...} }` -- Use `/api/auth/logout` to clear JWT cookie +- Use `/api/auth/logout` to clear session cookie - Never pass user data in URL params - security risk! **Attribute Parsing** (`app/api/saml/acs/route.ts` lines 27-34): @@ -138,9 +138,52 @@ const getAttr = (key: string): string | undefined => { **Security**: - Private key (`SAML_SP_PRIVATE_KEY`) signs requests and decrypts assertions - Public cert (`SAML_SP_CERT`) verified by Stanford IdP -- JWT tokens stored in HTTP-only cookies (not accessible to JavaScript) +- Session data encrypted in HTTP-only cookies using iron-session (not accessible to JavaScript) - Clock skew: 5 minutes (`acceptedClockSkewMs: 300000`) +### Authorization System + +**Two-Tier Access Control**: +1. **Global Access**: Users with specific `eduPersonEntitlement` values access everything +2. **Per-Application Access**: SUNet ID mappings grant access to specific applications + +**Environment Variables**: +- `CHURRO_GLOBAL_ENTITLEMENTS` - Comma-separated list (e.g., `uit:sws,stanford:faculty`) +- `CHURRO_APP_ACCESS` - UUID:uid mappings (e.g., `uuid1:jdoe,jsmith;uuid2:jdoe`) + +**Key Components** (`lib/auth-utils.ts`): +- `hasGlobalAccess(user)` - Check if user has global entitlement +- `hasApplicationAccess(user, uuid)` - Check specific app access +- `hasDashboardAccess(user)` - Check if can access dashboard (global access only) +- `parseAppAccessMappings()` - Parse environment variable mappings + +**Middleware Protection** (`middleware.ts`): +- `/` - Dashboard requires global access only +- `/applications/[uuid]` - Requires global access OR specific application access +- Returns 403 with clear error messages for unauthorized access + +**API Protection** (`lib/api-auth.ts`): +```typescript +export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { + // Protected API logic with user context + return NextResponse.json({ data: 'protected' }); + })(request); +} +``` + +**Client-Side Handling**: +- Authorization errors (403) display user-friendly error pages +- Client components check API response status and handle authorization failures +- Provides contact information and "Return to Dashboard" links + +**Authorization Flow**: +1. User authenticates via Stanford SAML +2. Middleware checks global entitlements first +3. If no global access, checks per-application mappings +4. Routes/APIs enforce access before rendering/processing +5. Clear error messages for authorization failures + ### Component Patterns **Client Components** - Use `'use client'` directive when: @@ -191,7 +234,7 @@ cp .env.example .env.local # Create env file # Edit .env.local: # APP_URL=https://localhost:3000 # SAML_ENTITY_ID=https://churro-test.stanford.edu (if needed) -# SESSION_SECRET= +# SESSION_SECRET= (REQUIRED for iron-session) # Start development server npm run dev:https # HTTPS server (required for SAML) @@ -220,11 +263,11 @@ npm run dev # HTTP server (basic development, no SAML) ### Checking Auth Status Client-Side ```typescript -// Check if user is authenticated +// Check if user is authenticated (reads encrypted session cookie) const response = await fetch('/api/auth/status') const { authenticated, user } = await response.json() -// Logout +// Logout (clears encrypted session cookie) window.location.href = '/api/auth/logout' ``` @@ -264,8 +307,8 @@ utilities/ # Helper utilities (datasource color mappings) 4. **Array vs single value** - SAML attributes may be arrays, use `getAttr()` helper 5. **Cache staleness** - 6-hour cache may hide API issues, check timestamps 6. **Decanter overrides** - Don't use arbitrary Tailwind values, use Decanter tokens -7. **User data in URLs** - Never pass sensitive user data in query params; use HTTP-only cookies -8. **Session secret missing** - Ensure `SESSION_SECRET` is set (required for session encryption) +7. **User data in URLs** - Never pass sensitive user data in query params; use encrypted iron-session cookies +8. **Session secret missing** - Ensure `SESSION_SECRET` is set (required for iron-session encryption) ## Key Documentation diff --git a/.gitignore b/.gitignore index b92cb84..4a494f7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ next-env.d.ts # saml saml-sp.key saml-sp.crt + +# Cache directory, if present +.cache diff --git a/README.md b/README.md index 284851b..7db7604 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,13 @@ Then open: | `SAML_SP_CERT` | Your SP certificate | Multi-line PEM format | | `SAML_SP_PRIVATE_KEY` | Your SP private key | Multi-line PEM format | +### Authorization Variables + +| Variable | Description | Example | +|----------|-------------|----------| +| `CHURRO_GLOBAL_ENTITLEMENTS` | eduPersonEntitlement values for global access | `uit:sws` or `uit:sws,other:entitlement` | +| `CHURRO_APP_ACCESS` | Per-application SUNet ID mappings | `uuid1:jdoe,jsmith;uuid2:jdoe` | + ### Optional Variables | Variable | Description | Default | diff --git a/app/api/acquia/applications/route.ts b/app/api/acquia/applications/route.ts index 463fc78..c09811f 100644 --- a/app/api/acquia/applications/route.ts +++ b/app/api/acquia/applications/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; +import { SamlUser } from '@/lib/session-auth'; +import { hasGlobalAccess, parseAppAccessMappings } from '@/lib/auth-utils'; export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { // console.log('🚀 Applications API Route called'); // Update the API service initialization with better error handling @@ -35,9 +39,22 @@ export async function GET(request: NextRequest) { const applications = await apiService.getApplications(); - // console.log('✅ Successfully fetched applications data, count:', applications.length); + // Apply user permission filtering server-side for security + const { user } = context; + let filteredApplications = applications; - return NextResponse.json(applications); + // If user doesn't have global access, filter to only authorized applications + if (!hasGlobalAccess(user)) { + const appMappings = parseAppAccessMappings(); + filteredApplications = applications.filter(app => { + const allowedUsers = appMappings.get(app.uuid); + return allowedUsers && user.sunetId && allowedUsers.has(user.sunetId); + }); + } + + // console.log('✅ Successfully fetched applications data, count:', filteredApplications.length); + + return NextResponse.json(filteredApplications); } catch (error) { console.error('❌ API Route Error:', error); @@ -55,4 +72,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } \ No newline at end of file diff --git a/app/api/acquia/views/route.ts b/app/api/acquia/views/route.ts index 0975826..d6a71e4 100644 --- a/app/api/acquia/views/route.ts +++ b/app/api/acquia/views/route.ts @@ -1,27 +1,30 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; +import { SamlUser } from '@/lib/session-auth'; export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const subscriptionUuid = searchParams.get('subscriptionUuid'); - const from = searchParams.get('from'); - const to = searchParams.get('to'); - const resolution = searchParams.get('resolution'); // Get granularity for daily data - /** - console.log('🚀 Views by Application API Route called with params:', { - subscriptionUuid, - from, - to, - resolution - }); - */ - if (!subscriptionUuid) { - console.error('❌ Missing required parameter: subscriptionUuid'); - return NextResponse.json( - { error: 'subscriptionUuid is required' }, - { status: 400 } - ); - } + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { + const searchParams = request.nextUrl.searchParams; + const subscriptionUuid = searchParams.get('subscriptionUuid'); + const from = searchParams.get('from'); + const to = searchParams.get('to'); + const resolution = searchParams.get('resolution'); // Get granularity for daily data + /** + console.log('🚀 Views by Application API Route called with params:', { + subscriptionUuid, + from, + to, + resolution + }); + */ + if (!subscriptionUuid) { + console.error('❌ Missing required parameter: subscriptionUuid'); + return NextResponse.json( + { error: 'subscriptionUuid is required' }, + { status: 400 } + ); + } if (!process.env.ACQUIA_API_KEY || !process.env.ACQUIA_API_SECRET) { console.error('❌ Missing required environment variables!'); @@ -82,4 +85,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } diff --git a/app/api/acquia/visits/route.ts b/app/api/acquia/visits/route.ts index 94104ae..5a85a2d 100644 --- a/app/api/acquia/visits/route.ts +++ b/app/api/acquia/visits/route.ts @@ -1,7 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; +import { SamlUser } from '@/lib/session-auth'; export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { const searchParams = request.nextUrl.searchParams; const subscriptionUuid = searchParams.get('subscriptionUuid'); const from = searchParams.get('from'); @@ -85,4 +88,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index dc98848..9946422 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -8,11 +8,22 @@ import { getBaseUrl } from '@/lib/url-utils' */ export async function GET(request: NextRequest) { const cookieStore = await cookies() + const { searchParams } = new URL(request.url) // Delete the JWT cookie cookieStore.delete(getSessionCookieName()) - // Redirect to home page or login page + // Check if redirect URL is specified (e.g., from test page) + const redirectTo = searchParams.get('redirectTo') const baseUrl = getBaseUrl(request) + + if (redirectTo) { + // Validate that redirect is to a safe path (starts with /) + if (redirectTo.startsWith('/')) { + return NextResponse.redirect(new URL(redirectTo, baseUrl)) + } + } + + // Default redirect to home page return NextResponse.redirect(new URL('/', baseUrl)) } diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index a085b91..c55091d 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -6,8 +6,9 @@ import { getBaseUrl } from '@/lib/url-utils' /** * Common SAML response processing logic for both POST and GET handlers */ -async function processSamlResponse(request: NextRequest, samlResponse: string) { +async function processSamlResponse(request: NextRequest, samlResponse: string, relayState?: string) { console.log('🔍 Processing SAML response with @node-saml/node-saml...') + console.log('🔗 RelayState:', relayState || 'None provided') const { profile } = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse }) @@ -77,14 +78,23 @@ async function processSamlResponse(request: NextRequest, samlResponse: string) { // - ~50-100ms API call overhead per auth check is acceptable for this use case // - Simpler implementation outweighs marginal performance gains // - Iron-session provides encryption for enhanced security without added complexity + console.log('🔐 Creating session for user...') await createSession(user) + console.log('✅ Session created successfully') - // Redirect to the application (or a relay state if available) + // Redirect to intermediate auth success page to handle client-side redirect + // This avoids timing issues with session cookies on server-side redirects to protected routes const baseUrl = getBaseUrl(request) - const redirectUrl = new URL('/auth/test', baseUrl) - redirectUrl.searchParams.set('saml_success', 'true') + const returnTo = relayState || '/' + const authSuccessUrl = new URL('/auth/success', baseUrl) - return Response.redirect(redirectUrl.toString(), 302) + // Pass the return URL as a query parameter for client-side redirect + if (returnTo !== '/') { + authSuccessUrl.searchParams.set('returnTo', returnTo) + } + + console.log('🔄 Redirecting to auth success page with returnTo:', returnTo) + return Response.redirect(authSuccessUrl.toString(), 302) } /** @@ -97,13 +107,14 @@ export async function POST(request: NextRequest) { try { const formData = await request.formData() const samlResponse = formData.get('SAMLResponse') as string + const relayState = formData.get('RelayState') as string if (!samlResponse) { throw new Error('No SAML response received in form data') } console.log('📨 POST: Processing SAML response from form data') - return await processSamlResponse(request, samlResponse) + return await processSamlResponse(request, samlResponse, relayState) } catch (error) { console.error('❌ SAML POST callback error:', error) diff --git a/app/api/saml/login/route.ts b/app/api/saml/login/route.ts index eedb24f..526eeb0 100644 --- a/app/api/saml/login/route.ts +++ b/app/api/saml/login/route.ts @@ -3,10 +3,18 @@ import { saml } from '@/lib/saml-config' export async function GET(request: NextRequest) { try { - const loginUrl = await saml.getAuthorizeUrlAsync('', '', {}) + // Get the return URL from query parameters (set by middleware) + const returnTo = request.nextUrl.searchParams.get('returnTo') || '/' + console.log('🚀 SAML Login initiated with returnTo:', returnTo) + + // Use RelayState to preserve the return URL through the SAML flow + console.log('🔗 Generating SAML login URL with RelayState...') + const loginUrl = await saml.getAuthorizeUrlAsync(returnTo, '', {}) + console.log('✅ SAML login URL generated:', loginUrl) + return NextResponse.redirect(loginUrl) } catch (error) { - console.error('Error initiating SAML login:', error) + console.error('❌ Error initiating SAML login:', error) return NextResponse.json({ error: 'Failed to initiate login' }, { status: 500 }) } } diff --git a/app/applications/[uuid]/page.tsx b/app/applications/[uuid]/page.tsx index f010300..9518bfe 100644 --- a/app/applications/[uuid]/page.tsx +++ b/app/applications/[uuid]/page.tsx @@ -41,6 +41,20 @@ export default function ApplicationDetailPage({ params }: any) { const [error, setError] = useState(null); const [dailyViews, setDailyViews] = useState([]); const [dailyVisits, setDailyVisits] = useState([]); + const [authError, setAuthError] = useState(null); + + // Check for authorization errors from API calls + const handleApiResponse = async (response: Response) => { + if (response.status === 403) { + const errorData = await response.json().catch(() => ({})); + setAuthError(errorData.error || 'Access denied. You do not have permission to view this application.'); + return null; + } + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + return response.json(); + }; // Fetch application name on mount or when subscriptionUuid changes useEffect(() => { @@ -48,10 +62,13 @@ export default function ApplicationDetailPage({ params }: any) { try { setLoadingStep('Fetching application info...'); const res = await fetch(`/api/acquia/applications?subscriptionUuid=${subscriptionUuid}`); - const apps = await res.json(); + const apps = await handleApiResponse(res); + if (!apps) return; // Authorization error handled by handleApiResponse + const app = Array.isArray(apps) ? apps.find((a: any) => a.uuid === typedParams.uuid) : null; setAppName(app ? app.name : ''); - } catch { + } catch (err) { + console.error('Error fetching app name:', err); setAppName(''); } finally { setLoadingStep(''); @@ -60,6 +77,31 @@ export default function ApplicationDetailPage({ params }: any) { if (subscriptionUuid) fetchAppName(); }, [subscriptionUuid, typedParams.uuid]); + // Show authorization error if user doesn't have access + if (authError) { + return ( +
+
+
+

Access Denied

+

{authError}

+

+ If you believe you should have access to this application, please contact your administrator. +

+ +
+
+
+ ); + } + const fetchAppDetail = async () => { setLoading(true); setLoadingStep('Fetching analytics data...'); @@ -80,10 +122,11 @@ export default function ApplicationDetailPage({ params }: any) { fetch(`/api/acquia/visits?${dailyQuery}`), ]); - const [dailyViewsRaw, dailyVisitsRaw]: [AcquiaApiResponse, AcquiaApiResponse] = await Promise.all([ - dailyViewsRes.ok ? dailyViewsRes.json() : {}, - dailyVisitsRes.ok ? dailyVisitsRes.json() : {}, - ]); + // Handle authorization errors + const dailyViewsRaw = await handleApiResponse(dailyViewsRes); + const dailyVisitsRaw = await handleApiResponse(dailyVisitsRes); + + if (!dailyViewsRaw || !dailyVisitsRaw) return; // Authorization error handled // Helper to process and aggregate daily data with proper types const processDailyData = (rawData: AcquiaApiResponse, metric: 'views' | 'visits'): DailyDataPoint[] => { @@ -115,8 +158,8 @@ export default function ApplicationDetailPage({ params }: any) { const overallViewsData = dailyViewsRaw.data || []; const overallVisitsData = dailyVisitsRaw.data || []; - const overallTotalViews = overallViewsData.reduce((sum, v) => sum + (v.views || 0), 0); - const overallTotalVisits = overallVisitsData.reduce((sum, v) => sum + (v.visits || 0), 0); + const overallTotalViews = overallViewsData.reduce((sum: number, v: any) => sum + (v.views || 0), 0); + const overallTotalVisits = overallVisitsData.reduce((sum: number, v: any) => sum + (v.visits || 0), 0); setViews(appTotalViews); setVisits(appTotalVisits); diff --git a/app/applications/page.tsx b/app/applications/page.tsx index e6421d2..e79ae02 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -1,89 +1,345 @@ -import React from 'react'; - -// Force dynamic rendering - don't try to pre-render at build time -export const dynamic = 'force-dynamic'; - -const BASE_URL = process.env.VERCEL_URL - ? `https://${process.env.VERCEL_URL}` - : process.env.NEXT_PUBLIC_BASE_URL - ? process.env.NEXT_PUBLIC_BASE_URL - : 'http://localhost:3000'; - -async function fetchData() { - const [appsRes, viewsRes, visitsRes] = await Promise.all([ - fetch(`${BASE_URL}/api/acquia/applications`), - fetch(`${BASE_URL}/api/acquia/views`), - fetch(`${BASE_URL}/api/acquia/visits`), - ]); - const [apps, viewsRaw, visitsRaw] = await Promise.all([ - appsRes.ok ? appsRes.json() : [], - viewsRes.ok ? viewsRes.json() : [], - visitsRes.ok ? visitsRes.json() : [], - ]); - - // Defensive: ensure arrays - const views = Array.isArray(viewsRaw) - ? viewsRaw - : viewsRaw && Array.isArray(viewsRaw.data) - ? viewsRaw.data - : []; - const visits = Array.isArray(visitsRaw) - ? visitsRaw - : visitsRaw && Array.isArray(visitsRaw.data) - ? visitsRaw.data - : []; - return { apps, views, visits }; +'use client'; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import CountUpTimer from '@/components/CountUpTimer'; + +// Helper function to get previous month date range +function getPreviousMonthRange() { + const now = new Date(); + const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastDayOfPreviousMonth = new Date(now.getFullYear(), now.getMonth(), 0); + + // Format dates as YYYY-MM-DD + const from = previousMonth.toISOString().split('T')[0]; + const to = lastDayOfPreviousMonth.toISOString().split('T')[0]; + + // Get formatted month name for display + const monthName = previousMonth.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric' + }); + + return { from, to, monthName }; +} + +interface Application { + uuid: string; + name: string; +} + +interface AppStats extends Application { + views: number; + visits: number; + viewsPct: number; + visitsPct: number; } -function getAppStats(apps: any[], views: { map: (arg0: (v: any) => any[]) => Iterable; reduce: (arg0: (sum: any, v: any) => any, arg1: number) => any; }, visits: { map: (arg0: (v: any) => any[]) => Iterable; reduce: (arg0: (sum: any, v: any) => any, arg1: number) => any; }) { - // Map views/visits by app uuid - const viewsByApp = Object.fromEntries(views.map(v => [v.uuid, v.views])); - const visitsByApp = Object.fromEntries(visits.map(v => [v.uuid, v.visits])); +interface User { + sunetId?: string; + email?: string; +} + +function getAppStats(apps: Application[], views: any[], visits: any[]): AppStats[] { + // Aggregate views/visits by app uuid (sum across all dates) + const viewsByApp: Record = {}; + const visitsByApp: Record = {}; + + // Ensure views and visits are arrays + const viewsArray = Array.isArray(views) ? views : []; + const visitsArray = Array.isArray(visits) ? visits : []; + + viewsArray.forEach((v) => { + const uuid = v.applicationUuid; + const viewCount = v.views || 0; + if (uuid && viewCount > 0) { + viewsByApp[uuid] = (viewsByApp[uuid] || 0) + viewCount; + } + }); + + visitsArray.forEach((v) => { + const uuid = v.applicationUuid; + const visitCount = v.visits || 0; + if (uuid && visitCount > 0) { + visitsByApp[uuid] = (visitsByApp[uuid] || 0) + visitCount; + } + }); + // Calculate totals - const totalViews = views.reduce((sum, v) => sum + v.views, 0); - const totalVisits = visits.reduce((sum, v) => sum + v.visits, 0); + const totalViews = Object.values(viewsByApp).reduce((sum, v) => sum + v, 0); + const totalVisits = Object.values(visitsByApp).reduce((sum, v) => sum + v, 0); // Merge stats - return apps.map(app => ({ + const result = apps.map(app => ({ ...app, views: viewsByApp[app.uuid] || 0, visits: visitsByApp[app.uuid] || 0, viewsPct: totalViews ? ((viewsByApp[app.uuid] || 0) / totalViews) * 100 : 0, visitsPct: totalVisits ? ((visitsByApp[app.uuid] || 0) / totalVisits) * 100 : 0, })); + + return result; } -export default async function ApplicationsPage() { - const { apps, views, visits } = await fetchData(); - const stats = getAppStats(apps, views, visits); +export default function ApplicationsPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [stats, setStats] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMetrics, setLoadingMetrics] = useState(false); + const [error, setError] = useState(null); + const [monthName, setMonthName] = useState(''); + const [loadTime, setLoadTime] = useState(null); + + // Check authentication and load applications first + useEffect(() => { + async function checkAuthAndLoadApps() { + try { + // Check authentication + const authResponse = await fetch('/api/auth/status'); + if (!authResponse.ok) { + router.push('/api/saml/login'); + return; + } + + const { authenticated, user: userData } = await authResponse.json(); + if (!authenticated) { + router.push('/api/saml/login'); + return; + } + + setUser(userData); + + // Get month name + const { monthName: currentMonthName } = getPreviousMonthRange(); + setMonthName(currentMonthName); + + // Load applications quickly first + const appsResponse = await fetch('/api/acquia/applications'); + if (!appsResponse.ok) { + throw new Error(`Failed to load applications: ${appsResponse.status}`); + } + + const appsData = await appsResponse.json(); + + // Filter out excluded UUIDs (user permission filtering handled server-side) + const excludedUuids = ['2b2d2517-3839-414e-85a4-7183adc22283', '1ef402a7-c301-42d7-9b63-f226fa1b2329']; + const filteredApps = appsData.filter((app: Application) => !excludedUuids.includes(app.uuid)); + + // Sort applications alphabetically by name + const sortedApps = filteredApps.sort((a: Application, b: Application) => + a.name.localeCompare(b.name) + ); + + // Initialize stats with just application info (no metrics yet) + const initialStats: AppStats[] = sortedApps.map((app: Application) => ({ + ...app, + views: 0, + visits: 0, + viewsPct: 0, + visitsPct: 0, + })); + setStats(initialStats); + setLoading(false); + + // Now start loading the metrics data with timer + setLoadingMetrics(true); + await loadMetricsData(sortedApps); + + } catch (err) { + console.error('Error loading applications:', err); + setError(err instanceof Error ? err.message : 'Failed to load applications'); + setLoading(false); + } + } + + checkAuthAndLoadApps(); + }, [router]); + + async function loadMetricsData(apps: Application[]) { + const startTime = Date.now(); + + try { + const { from, to } = getPreviousMonthRange(); + const subscriptionUuid = process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID; + + // Fetch views and visits data with retry logic for 503 errors + const fetchWithRetry = async (url: string, retries = 3): Promise => { + for (let i = 0; i < retries; i++) { + const response = await fetch(url); + + if (response.status === 503) { + // Wait before retrying (exponential backoff) + await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)); + continue; + } + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + return response; + } + throw new Error('Service temporarily unavailable (503). Please try again later.'); + }; + + const [viewsResponse, visitsResponse] = await Promise.all([ + fetchWithRetry(`/api/acquia/views?subscriptionUuid=${subscriptionUuid}&from=${from}&to=${to}`), + fetchWithRetry(`/api/acquia/visits?subscriptionUuid=${subscriptionUuid}&from=${from}&to=${to}`) + ]); + + const [viewsData, visitsData] = await Promise.all([ + viewsResponse.json(), + visitsResponse.json() + ]); + + // Handle different response formats - API routes wrap data in { data: [...] } + const viewsArray = Array.isArray(viewsData) ? viewsData : + Array.isArray(viewsData.data) ? viewsData.data : []; + + const visitsArray = Array.isArray(visitsData) ? visitsData : + Array.isArray(visitsData.data) ? visitsData.data : []; + + // Calculate final stats + const finalStats = getAppStats(apps, viewsArray, visitsArray); + setStats(finalStats); + + const endTime = Date.now(); + setLoadTime((endTime - startTime) / 1000); + setLoadingMetrics(false); + + } catch (err) { + console.error('Error loading metrics:', err); + setError(err instanceof Error ? err.message : 'Failed to load metrics data'); + setLoadingMetrics(false); + } + } + + if (loading) { + return ( +
+

Application Views & Visits

+
+
+

Loading applications...

+
+
+ ); + } + + if (error) { + return ( +
+

Application Views & Visits ({monthName})

+
+

Error Loading Data

+

{error}

+ +
+
+ ); + } + + if (stats.length === 0) { + return ( +
+

Application Views & Visits ({monthName})

+
+

No Applications Available

+

+ You don't have access to any applications. Contact your administrator if you believe this is an error. +

+

+ Logged in as: {user?.sunetId || user?.email || 'Unknown user'} +

+
+
+ ); + } return ( -
-

Application Views & Visits

- - - - - - - - - - - - - {stats.map(app => ( - - - - - - - +
+

Application Views & Visits ({monthName})

+ + {loadingMetrics && ( +
+
+

Loading usage metrics...

+ +
+
+ )} + +
+
ApplicationUUIDViews% of ViewsVisits% of Visits
{app.name}{app.uuid}{app.views.toLocaleString()}{app.viewsPct.toFixed(1)}%{app.visits.toLocaleString()}{app.visitsPct.toFixed(1)}%
+ + + + + + + + - ))} - -
ApplicationUUIDViews% of ViewsVisits% of Visits
+ + + {stats.map(app => ( + + + + {app.name} + + + + + {app.uuid} + + + + {loadingMetrics ? ( + Loading... + ) : ( + app.views.toLocaleString() + )} + + + {loadingMetrics ? ( + — + ) : ( + `${app.viewsPct.toFixed(1)}%` + )} + + + {loadingMetrics ? ( + Loading... + ) : ( + app.visits.toLocaleString() + )} + + + {loadingMetrics ? ( + — + ) : ( + `${app.visitsPct.toFixed(1)}%` + )} + + + ))} + + +
); } \ No newline at end of file diff --git a/app/auth/success/page.tsx b/app/auth/success/page.tsx new file mode 100644 index 0000000..748e908 --- /dev/null +++ b/app/auth/success/page.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useEffect, Suspense } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' + +function AuthSuccessContent() { + const router = useRouter() + const searchParams = useSearchParams() + + useEffect(() => { + // Get the return URL from query parameters + const returnTo = searchParams.get('returnTo') || '/' + + // Small delay to ensure session cookie is available + const timer = setTimeout(() => { + console.log('✅ Authentication successful, redirecting to:', returnTo) + router.replace(returnTo) + }, 100) // 100ms delay to ensure session is ready + + return () => clearTimeout(timer) + }, [router, searchParams]) + + return ( +
+
+
+

+ Authentication Successful +

+

+ Redirecting you to your requested page... +

+
+
+ ) +} + +function LoadingFallback() { + return ( +
+
+
+

+ Processing Authentication... +

+
+
+ ) +} + +export default function AuthSuccessPage() { + return ( + }> + + + ) +} \ No newline at end of file diff --git a/app/auth/test/page.tsx b/app/auth/test/page.tsx index cbe4424..b87df06 100644 --- a/app/auth/test/page.tsx +++ b/app/auth/test/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react" import { useSearchParams } from 'next/navigation' import { Suspense } from 'react' +import LogoutButton from '@/components/LogoutButton' // The actual test component function AuthTestContent() { @@ -52,95 +53,118 @@ function AuthTestContent() { window.location.href = '/api/saml/login' } - const handleLogout = () => { - window.location.href = '/api/auth/logout' - } - if (loading) { return ( -
-

🔐 SAML Authentication Test

-
Loading authentication status...
+
+
+

🔐 SAML Authentication Test

+
+
+ Loading authentication status... +
+
) } if (authenticated && user) { return ( -
-

✅ Authentication SUCCESS

- - {message && ( -
- {message} +
+
+
+ {/* Header with logout button */} +
+

✅ Authentication SUCCESS

+ +
+ + {/* Success message */} + {message && ( +
+

{message}

+
+ )} + + {/* User data display */} +
+

User Profile:

+
+
+                  {JSON.stringify(user, null, 2)}
+                
+
+
+ + {/* Info panel */} +
+

â„šī¸ Session Information

+
+

â€ĸ Your session is stored in a secure HTTP-only encrypted cookie

+

â€ĸ Session data is validated by middleware on each request

+

â€ĸ Session expires in 24 hours from login

+

â€ĸ All authentication is handled server-side for security

+
+
- )} - -

User Data:

-
-          {JSON.stringify(user, null, 2)}
-        
- - - -
-

â„šī¸ Authentication Info:

-

Your session is stored in a secure HTTP-only cookie and validated by the middleware.

-

Token expires in 24 hours from login.

) } return ( -
-

🔐 SAML Authentication Test

- - {message && ( -
- {message} +
+
+
+

🔐 SAML Authentication Test

+ + {/* Error/Success messages */} + {message && ( +
+

{message}

+
+ )} + + {/* Login button */} + + + {/* Debug info */} +
+

Development Tools:

+
+
+

Test Links:

+ +
+
+
- )} - - - -
-

Debug Info:

- -

Test Links:

-
) @@ -149,9 +173,12 @@ function AuthTestContent() { // Loading component function Loading() { return ( -
-

🔐 SAML Authentication Test

-
Loading...
+
+
+

🔐 SAML Authentication Test

+
+
Loading...
+
) } diff --git a/app/error/page.tsx b/app/error/page.tsx new file mode 100644 index 0000000..5c10320 --- /dev/null +++ b/app/error/page.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useSearchParams, useRouter } from 'next/navigation'; +import { useEffect, useState, Suspense } from 'react'; + +function ErrorContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [errorType, setErrorType] = useState(''); + const [message, setMessage] = useState(''); + + useEffect(() => { + setErrorType(searchParams.get('type') || ''); + setMessage(searchParams.get('message') || 'An error occurred.'); + }, [searchParams]); + + const getErrorTitle = () => { + switch (errorType) { + case 'application-access': + return 'Application Access Denied'; + case 'dashboard-access': + return 'Dashboard Access Denied'; + default: + return 'Access Denied'; + } + }; + + const getErrorDescription = () => { + switch (errorType) { + case 'application-access': + return 'You do not have the necessary permissions to view this application. If you believe you should have access, please contact your system administrator.'; + case 'dashboard-access': + return 'You do not have the necessary permissions to view the dashboard. You may have access to specific applications. If you believe you should have dashboard access, please contact your system administrator.'; + default: + return 'You do not have permission to access this resource. Please contact your system administrator if you believe this is an error.'; + } + }; + + const handleReturnToDashboard = () => { + router.push('/'); + }; + + const handleSignOut = () => { + window.location.href = '/api/auth/logout'; + }; + + return ( +
+
+
+ {/* Error icon */} +
+ + + +
+ +

+ {getErrorTitle()} +

+ +

+ {message} +

+ +

+ {getErrorDescription()} +

+
+ +
+ {errorType !== 'dashboard-access' && ( + + )} + + +
+ +
+

Need help? Contact your system administrator.

+
+
+
+ ); +} + +function ErrorFallback() { + return ( +
+
+
+ {/* Error icon */} +
+ + + +
+ +

+ Access Denied +

+ +

+ Loading error details... +

+
+
+
+ ); +} + +export default function ErrorPage() { + return ( + }> + + + ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 437340f..6419437 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from 'next'; import { Source_Sans_3, Source_Serif_4 } from 'next/font/google'; import localFont from 'next/font/local'; import { GlobalFooter } from '@/components/GlobalFooter/GlobalFooter'; +import AuthenticatedHeader from '@/components/AuthenticatedHeader'; import { cnb } from 'cnbuilder'; const source_sans = Source_Sans_3({ @@ -62,39 +63,6 @@ export const metadata: Metadata = { }, }; -function StanfordHeader() { - return ( -
- - -
- ); -} - export default function RootLayout({ children, }: { @@ -112,7 +80,10 @@ export default function RootLayout({ source_serif.variable, stanford.variable, )}> - +
{children}
diff --git a/components/AuthenticatedHeader.tsx b/components/AuthenticatedHeader.tsx new file mode 100644 index 0000000..d6672c2 --- /dev/null +++ b/components/AuthenticatedHeader.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useState, useEffect } from 'react' +import LogoutButton from '@/components/LogoutButton' +import type { SamlUser } from '@/lib/session-auth' + +interface AuthenticatedHeaderProps { + title: string +} + +export default function AuthenticatedHeader({ title }: AuthenticatedHeaderProps) { + const [user, setUser] = useState(null) + const [authenticated, setAuthenticated] = useState(false) + const [loading, setLoading] = useState(true) + + useEffect(() => { + checkAuthStatus() + }, []) + + const checkAuthStatus = async () => { + try { + const response = await fetch('/api/auth/status') + if (response.ok) { + const data = await response.json() + setAuthenticated(data.authenticated) + setUser(data.user) + } + } catch (error) { + console.error('Error checking auth status:', error) + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Stanford Header Bar */} + + + {/* Main Header */} +
+ {/* Logo and Title */} + + + {/* User Info and Logout */} + {authenticated && !loading && ( +
+
+

+ {user?.name || user?.displayName || 'Stanford User'} +

+

+ {user?.sunetId && `${user.sunetId}@stanford.edu`} +

+
+ +
+ )} + + {/* Loading state */} + {loading && ( +
+
+ Loading... +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx new file mode 100644 index 0000000..1f8aa5a --- /dev/null +++ b/components/LogoutButton.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useState } from 'react' + +interface LogoutButtonProps { + redirectTo?: string + variant?: 'primary' | 'secondary' | 'text' + size?: 'small' | 'medium' + className?: string +} + +export default function LogoutButton({ + redirectTo = '/auth/test', + variant = 'secondary', + size = 'medium', + className = '' +}: LogoutButtonProps) { + const [isLoading, setIsLoading] = useState(false) + + const handleLogout = async () => { + setIsLoading(true) + try { + // Use the logout API route with optional redirect + const logoutUrl = new URL('/api/auth/logout', window.location.origin) + if (redirectTo) { + logoutUrl.searchParams.set('redirectTo', redirectTo) + } + window.location.href = logoutUrl.toString() + } catch (error) { + console.error('Logout failed:', error) + setIsLoading(false) + } + } + + // Base classes using Decanter design tokens + const baseClasses = 'inline-flex items-center justify-center font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 hocus:no-underline' + + // Variant styles + const variantClasses = { + primary: 'bg-cardinal-red text-white hocus:bg-black hocus:text-white focus:ring-cardinal-red', + secondary: 'border border-black text-black bg-transparent hocus:bg-black hocus:text-white focus:ring-black', + text: 'text-cardinal-red hocus:text-black hocus:underline focus:ring-cardinal-red' + } + + // Size styles + const sizeClasses = { + small: 'px-15 py-8 text-16', + medium: 'px-20 py-10 text-18' + } + + const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}` + + return ( + + ) +} \ No newline at end of file diff --git a/docs/SAML.md b/docs/SAML.md index 1a23e95..61f95f7 100644 --- a/docs/SAML.md +++ b/docs/SAML.md @@ -9,7 +9,7 @@ This implementation provides: - ✅ **Signed authentication requests** for simplified endpoint management - ✅ **Encrypted assertion decryption** using RSA-OAEP + AES-CBC - ✅ **Signature verification** of SAML responses and assertions -- ✅ **Encrypted session management** with HTTP-only cookies using iron-session +- ✅ **Encrypted session management** with HTTP-only cookies using iron-session (AES-256-GCM) - ✅ **Full Stanford attribute mapping** (SUNet ID, email, affiliation, etc.) - ✅ **Next.js App Router compatibility** with middleware protection - ✅ **Production-ready security** @@ -25,8 +25,8 @@ sequenceDiagram participant Stanford as Stanford IdP User->>App: Access protected resource - Middleware->>Middleware: Check JWT cookie - Middleware->>SAML: No valid JWT, redirect to /api/saml/login + Middleware->>Middleware: Check encrypted session cookie + Middleware->>SAML: No valid session, redirect to /api/saml/login SAML->>Stanford: Signed SAML AuthnRequest Stanford->>User: Login form User->>Stanford: Credentials @@ -34,8 +34,8 @@ sequenceDiagram SAML->>SAML: Verify signatures SAML->>SAML: Decrypt assertion SAML->>SAML: Extract user attributes - SAML->>SAML: Generate encrypted session token (iron-session) - SAML->>User: Set HTTP-only encrypted cookie & redirect + SAML->>SAML: Create encrypted session (iron-session) + SAML->>User: Set HTTP-only encrypted session cookie & redirect User->>App: Access resource with encrypted session cookie Middleware->>Middleware: Verify encrypted session, add user headers Middleware->>App: Allow access @@ -446,16 +446,16 @@ export async function middleware(request: NextRequest) { const isProtectedRoute = request.nextUrl.pathname.startsWith('/protected') if (isProtectedRoute) { - const payload = await verifySession() - if (!payload) { + const user = await verifySession() + if (!user) { return NextResponse.redirect(new URL('/api/saml/login', request.url)) } // Add user info to request headers for downstream use const requestHeaders = new Headers(request.headers) - requestHeaders.set('x-user-id', payload.id) - if (payload.sunetId) requestHeaders.set('x-user-sunetid', payload.sunetId) - if (payload.email) requestHeaders.set('x-user-email', payload.email) + requestHeaders.set('x-user-id', user.id) + if (user.sunetId) requestHeaders.set('x-user-sunetid', user.sunetId) + if (user.email) requestHeaders.set('x-user-email', user.email) return NextResponse.next({ request: { headers: requestHeaders }, @@ -513,6 +513,142 @@ export async function GET(request: NextRequest) { } ``` +## Authorization System + +After successful SAML authentication, CHURRO implements a comprehensive two-tier authorization system to control access to resources. + +### Authorization Levels + +1. **Global Access**: Users with specific `eduPersonEntitlement` values can access the entire application +2. **Per-Application Access**: SUNet ID-based mappings grant access to specific applications only + +### Environment Variables + +Add these to your `.env.local`: + +```env +# Global access via Stanford entitlements +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws + +# Per-application access mappings (format: uuid:uid1,uid2;uuid:uid3) +CHURRO_APP_ACCESS=app-uuid-1:jdoe,jsmith;app-uuid-2:jdoe +``` + +### Configuration Examples + +#### Global Entitlement Access +```env +# All users with "uit:sws" entitlement can access everything +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws + +# Multiple entitlements (comma-separated) +CHURRO_GLOBAL_ENTITLEMENTS=uit:sws,uit:admin,stanford:faculty +``` + +#### Per-Application Access Mappings +```env +# Format: applicationUuid:uid1,uid2,uid3;anotherUuid:uid4,uid5 +CHURRO_APP_ACCESS=12345678-1234-1234-1234-123456789abc:jdoe,jsmith;87654321-4321-4321-4321-cba987654321:jdoe +``` + +### Authorization Components + +#### 1. Authorization Utilities (`lib/auth-utils.ts`) + +Core functions for access checking: + +```typescript +import { hasGlobalAccess, hasApplicationAccess, hasDashboardAccess } from '@/lib/auth-utils'; + +// Check if user has global access via entitlements +const canAccessAll = hasGlobalAccess(user); + +// Check access to specific application +const canAccessApp = hasApplicationAccess(user, 'app-uuid-here'); + +// Check if user can access dashboard (global access only) +const canAccessDashboard = hasDashboardAccess(user); +``` + +#### 2. Middleware Protection (`middleware.ts`) + +Automatic route-level authorization enforcement: + +- **Dashboard**: `/` - Requires global access OR access to at least one application +- **Applications**: `/applications/[uuid]` - Requires global access OR specific application access +- **API Routes**: Protected via `withApiAuthorization` wrapper + +#### 3. API Authorization (`lib/api-auth.ts`) + +Server-side API route protection: + +```typescript +import { withApiAuthorization } from '@/lib/api-auth'; + +export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { + // Your protected API logic here + const { user } = context; + return NextResponse.json({ data: 'Protected data' }); + })(request); +} +``` + +### Access Control Flow + +1. **Authentication**: User authenticates via Stanford SAML +2. **Global Check**: System checks if user has global entitlement (e.g., `uit:sws`) +3. **Per-App Check**: If no global access, check specific application mappings +4. **Route Protection**: Middleware enforces access rules before rendering pages +5. **API Protection**: API routes verify authorization before processing requests + +### User Experience + +#### Global Access Users +- Can access dashboard showing all applications +- Can view any application detail page +- Have full access to all API endpoints + +#### Per-Application Access Users +- Can access dashboard (shows only their authorized applications) +- Can only view detail pages for applications they have access to +- API calls filtered to only return authorized application data + +#### Unauthorized Users +- Receive 403 Forbidden with clear error messages +- Redirected to appropriate pages with access denial information +- Provided contact information for requesting access + +### Debugging Authorization + +Enable authorization debugging: + +```typescript +// In lib/auth-utils.ts, uncomment debug logs +console.log('🔐 Authorization check:', { hasGlobal, hasApp, result }); +``` + +Common debug scenarios: + +```bash +# Check user's entitlements +console.log('User entitlements:', user.entitlements); + +# Check parsed app mappings +console.log('App access mappings:', parseAppAccessMappings()); + +# Check specific access decision +console.log('Access decision for app XYZ:', hasApplicationAccess(user, 'xyz-uuid')); +``` + +### Configuration Best Practices + +1. **Production Security**: Never commit real SUNet IDs or UUIDs to version control +2. **Entitlement Standards**: Use Stanford's standard entitlement format (`department:role`) +3. **UUID Management**: Use actual Acquia application UUIDs from the API +4. **Access Reviews**: Regularly audit access mappings in production +5. **Error Handling**: Provide clear error messages for authorization failures + ## Stanford Attribute Mapping The implementation automatically maps Stanford's SAML attributes using official OID identifiers: @@ -616,11 +752,11 @@ http://localhost:3000/auth/test To check authentication status from client-side code: ```typescript -// Check authentication +// Check authentication (reads encrypted session cookie server-side) const response = await fetch('/api/auth/status') const { authenticated, user } = await response.json() -// Logout +// Logout (clears encrypted session cookie) window.location.href = '/api/auth/logout' ``` @@ -653,10 +789,10 @@ window.location.href = '/api/auth/logout' - Ensure server time is accurate (use NTP) #### 5. "Session verification failed" -- Verify `SESSION_SECRET` is set correctly (used as encryption password) -- Check that the session cookie hasn't been tampered with +- Verify `SESSION_SECRET` is set correctly (used as iron-session encryption password) +- Check that the encrypted session cookie hasn't been tampered with - Ensure session hasn't expired (24-hour expiration) -- Verify iron-session configuration matches between encryption and decryption +- Verify iron-session configuration matches between session creation and verification ### Debug Logging @@ -664,17 +800,17 @@ Enable detailed logging by checking the console output: ```typescript // In acs/route.ts, these logs help debug: -console.log('🔍 Processing SAML response...') +console.log('🔍 Processing SAML response with @node-saml/node-saml...') console.log('✅ SAML validation succeeded!') -console.log('📋 Profile:', JSON.stringify(profile, null, 2)) +console.log('✅ Successfully parsed user:', user.sunetId || user.email || user.id) ``` ## Next Steps 1. **Protected Routes** - Use middleware to protect routes requiring authentication -3. **Session Management** - Encrypted sessions are automatically managed via HTTP-only cookies +2. **Session Management** - Encrypted iron-session cookies are automatically managed via HTTP-only cookies 3. **Authorization** - Use SAML attributes (eduPersonEntitlement, affiliation) for role-based access control -4. **Monitoring** - Add logging and error tracking for SAML and encrypted session operations +4. **Monitoring** - Add logging and error tracking for SAML and iron-session operations ## References diff --git a/lib/api-auth.ts b/lib/api-auth.ts new file mode 100644 index 0000000..ab3c143 --- /dev/null +++ b/lib/api-auth.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCurrentUser, hasApplicationAccess, hasGlobalAccess } from '@/lib/auth-utils' + +/** + * Middleware helper for API route authorization + * + * This provides authorization checking specifically for API routes. + * Use this in individual API route handlers where you need to check + * user permissions before processing the request. + */ + +/** + * Check if the current user is authorized to access API endpoints + * @param request NextRequest object + * @param requireGlobalAccess If true, require global access (not just app-specific) + * @returns Promise<{authorized: boolean, user: SamlUser | null, error?: string}> + */ +export async function checkApiAuthorization( + request: NextRequest, + requireGlobalAccess = false +) { + try { + const user = await getCurrentUser() + + if (!user) { + return { + authorized: false, + user: null, + error: 'Authentication required' + } + } + + if (requireGlobalAccess) { + const hasGlobal = hasGlobalAccess(user) + if (!hasGlobal) { + return { + authorized: false, + user, + error: 'Global access required. Contact administrator for access.' + } + } + } + + return { + authorized: true, + user + } + } catch (error) { + return { + authorized: false, + user: null, + error: 'Authorization check failed' + } + } +} + +/** + * Check if the current user is authorized to access a specific application via API + * @param request NextRequest object + * @param applicationUuid Application UUID to check access for + * @returns Promise<{authorized: boolean, user: SamlUser | null, error?: string}> + */ +export async function checkApplicationApiAuthorization( + request: NextRequest, + applicationUuid: string +) { + try { + const user = await getCurrentUser() + + if (!user) { + return { + authorized: false, + user: null, + error: 'Authentication required' + } + } + + const hasAccess = hasApplicationAccess(user, applicationUuid) + if (!hasAccess) { + return { + authorized: false, + user, + error: `Access denied. You do not have permission to access application ${applicationUuid}.` + } + } + + return { + authorized: true, + user + } + } catch (error) { + return { + authorized: false, + user: null, + error: 'Authorization check failed' + } + } +} + +/** + * Create a standardized unauthorized response + * @param error Error message + * @param status HTTP status code (default: 403) + * @returns NextResponse with error + */ +export function createUnauthorizedResponse(error: string, status = 403) { + return NextResponse.json( + { + error, + code: status === 401 ? 'AUTHENTICATION_REQUIRED' : 'ACCESS_DENIED' + }, + { status } + ) +} + +/** + * Wrapper function to protect API routes with authorization + * Usage: + * + * export const GET = withApiAuthorization(async (request, { user }) => { + * // Your API logic here, user is guaranteed to be authenticated and authorized + * return NextResponse.json({ data: 'authorized data' }) + * }, { requireGlobalAccess: true }) + */ +export function withApiAuthorization( + handler: (request: NextRequest, context: { user: NonNullable>> }) => Promise, + options: { requireGlobalAccess?: boolean; requireApplicationUuid?: boolean } = {} +) { + return async (request: NextRequest) => { + // Check if application UUID is required and extract it from URL + let applicationUuid: string | undefined + + if (options.requireApplicationUuid) { + const url = new URL(request.url) + // Try to extract UUID from path segments or query params + const pathSegments = url.pathname.split('/') + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + applicationUuid = pathSegments.find(segment => uuidRegex.test(segment)) || + url.searchParams.get('uuid') || + url.searchParams.get('applicationUuid') || + undefined + + if (!applicationUuid) { + return createUnauthorizedResponse('Application UUID required', 400) + } + } + + // Perform authorization check + const authResult = applicationUuid + ? await checkApplicationApiAuthorization(request, applicationUuid) + : await checkApiAuthorization(request, options.requireGlobalAccess) + + if (!authResult.authorized || !authResult.user) { + const status = authResult.error?.includes('Authentication required') ? 401 : 403 + return createUnauthorizedResponse(authResult.error || 'Access denied', status) + } + + // Call the actual handler with the authorized user + return handler(request, { user: authResult.user }) + } +} \ No newline at end of file diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts index 05f9a16..a35def1 100644 --- a/lib/auth-utils.ts +++ b/lib/auth-utils.ts @@ -1,5 +1,11 @@ import { getSessionCookieName, type SamlUser } from '@/lib/session-auth' +// Cache the parsed app access mappings to avoid re-parsing on every request +let cachedAppAccessMappings: Map> | null = null + +// Cache the parsed global entitlements to avoid re-parsing on every request +let cachedGlobalEntitlements: Set | null = null + /** * Get the current authenticated user from session (server-side) * For use in Server Components and API routes @@ -12,6 +18,107 @@ export async function getCurrentUser(): Promise { return payload } +/** + * Parse application access mappings from environment variable + * Format: uuid1:uid1,uid2;uuid2:uid3,uid4 + * Cached at module level for performance + */ +export function parseAppAccessMappings(): Map> { + // Return cached result if available + if (cachedAppAccessMappings !== null) { + return cachedAppAccessMappings + } + + const mappings = new Map>() + const envVar = process.env.CHURRO_APP_ACCESS + + if (!envVar) { + cachedAppAccessMappings = mappings + return mappings + } + + // Split by semicolon to get each app mapping + const appMappings = envVar.split(';') + + for (const appMapping of appMappings) { + const [uuid, uidsStr] = appMapping.split(':') + if (uuid && uidsStr) { + const uids = new Set( + uidsStr.split(',') + .map(uid => uid.trim()) + .filter(uid => uid.length > 0) + ) + mappings.set(uuid.trim(), uids) + } + } + + // Cache the result + cachedAppAccessMappings = mappings + return mappings +} + +/** + * Parse global entitlements from environment variable + * Cached at module level for performance + */ +function parseGlobalEntitlements(): Set { + // Return cached result if available + if (cachedGlobalEntitlements !== null) { + return cachedGlobalEntitlements + } + + const globalEntitlements = process.env.CHURRO_GLOBAL_ENTITLEMENTS + const entitlements = new Set() + + if (globalEntitlements) { + globalEntitlements.split(',').forEach(e => entitlements.add(e.trim())) + } + + // Cache the result + cachedGlobalEntitlements = entitlements + return entitlements +} + +/** + * Check if user has global access via eduPersonEntitlement + */ +export function hasGlobalAccess(user: SamlUser): boolean { + if (!user.eduPersonEntitlement) { + return false + } + + const allowedEntitlements = parseGlobalEntitlements() + return allowedEntitlements.has(user.eduPersonEntitlement) +} + +/** + * Check if user has access to a specific application + */ +export function hasApplicationAccess(user: SamlUser, appUuid: string): boolean { + // Global access users can access everything + if (hasGlobalAccess(user)) { + return true + } + + // Check per-application access + const appMappings = parseAppAccessMappings() + const allowedUsers = appMappings.get(appUuid) + + if (!allowedUsers || !user.sunetId) { + return false + } + + return allowedUsers.has(user.sunetId) +} + +/** + * Check if user can access the dashboard + * Dashboard access requires global access (not per-app access) + */ +export function hasDashboardAccess(user: SamlUser): boolean { + return hasGlobalAccess(user) +} + /** * Client-side hook to check authentication status * Since cookies are HTTP-only, client can only check if they're logged in diff --git a/middleware.ts b/middleware.ts index 14ae7a5..eca336f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,49 +1,84 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -import { verifySession, getSessionCookieName } from '@/lib/session-auth'; +import { verifySession } from '@/lib/session-auth'; +import { hasApplicationAccess, hasDashboardAccess } from '@/lib/auth-utils'; /** - * Middleware for JWT authentication + * Middleware for session authentication and authorization * - * DESIGN DECISION: Authentication (AuthN) vs Authorization (AuthZ) + * IMPLEMENTATION: Authentication (AuthN) + Authorization (AuthZ) * - * This middleware currently implements AUTHENTICATION ONLY: - * - Verifies user identity via JWT tokens from SAML SSO - * - Protects routes with the /protected/* prefix - * - Adds user identity headers for authenticated requests + * This middleware implements both AUTHENTICATION and AUTHORIZATION: + * - Verifies user identity via encrypted sessions from SAML SSO + * - Enforces authorization rules based on eduPersonEntitlement and uid mappings + * - Protects application routes and dashboard based on user permissions * - * API routes (/api/*) are intentionally NOT protected because: - * 1. This branch implements AuthN only - AuthZ is future work - * 2. API routes currently return all data for a given subscription UUID - * 3. There's no user-data association or permission filtering yet - * 4. Main application routes (/, /applications/*) are also public + * Authorization Rules: + * 1. Global Access: Users with configured eduPersonEntitlement (e.g., 'uit:sws') + * can access everything + * 2. Per-App Access: Individual uid mappings grant access to specific applications + * 3. Dashboard Access: Granted only to users with global access + * (per-app users should go directly to their authorized applications) * - * FUTURE AuthZ work should: - * - Associate users with specific applications/data they can access - * - Filter API responses based on user permissions - * - Implement role-based access control (RBAC) - * - Then protect API routes to enforce these permissions + * API routes (/api/*) are intentionally NOT protected in middleware because: + * - API-level authorization is implemented in individual route handlers + * - This allows for more granular permission checking with request context + * - Different APIs may have different authorization requirements */ export async function middleware(request: NextRequest) { - // For protected routes, check authentication + // Check for protected routes that always require authentication const isProtectedRoute = request.nextUrl.pathname.startsWith('/protected'); - if (isProtectedRoute) { + // Check for application-specific routes + const appRouteMatch = request.nextUrl.pathname.match(/^\/applications\/([a-f0-9-]+)(?:\/.*)?$/); + const isApplicationRoute = !!appRouteMatch; + const appUuid = appRouteMatch?.[1]; + + // Check for dashboard route + const isDashboardRoute = request.nextUrl.pathname === '/'; + + // Routes that need authorization checking + const needsAuth = isProtectedRoute || isApplicationRoute || isDashboardRoute; + + if (needsAuth) { // Verify the session - const payload = await verifySession(); - if (!payload) { - // Invalid session, redirect to SAML login - return NextResponse.redirect(new URL('/api/saml/login', request.url)); + const user = await verifySession(); + if (!user) { + // Invalid session, redirect to SAML login with return URL + const loginUrl = new URL('/api/saml/login', request.url); + loginUrl.searchParams.set('returnTo', request.nextUrl.pathname + request.nextUrl.search); + return NextResponse.redirect(loginUrl); } - // Token is valid, add user info to request headers for downstream use + // Authorization checks + if (isApplicationRoute && appUuid) { + if (!hasApplicationAccess(user, appUuid)) { + // User doesn't have access to this specific application + // Redirect to error page with context for better UX + const errorUrl = new URL('/error', request.url); + errorUrl.searchParams.set('type', 'application-access'); + errorUrl.searchParams.set('message', 'You do not have permission to view this application.'); + return NextResponse.redirect(errorUrl); + } + } else if (isDashboardRoute) { + if (!hasDashboardAccess(user)) { + // User doesn't have access to dashboard + // Redirect to error page with context for better UX + const errorUrl = new URL('/error', request.url); + errorUrl.searchParams.set('type', 'dashboard-access'); + errorUrl.searchParams.set('message', 'You do not have permission to access the dashboard.'); + return NextResponse.redirect(errorUrl); + } + } + + // Session is valid and user is authorized, add user info to request headers const requestHeaders = new Headers(request.headers); - requestHeaders.set('x-user-id', payload.id); - if (payload.sunetId) { - requestHeaders.set('x-user-sunetid', payload.sunetId); + requestHeaders.set('x-user-id', user.id); + if (user.sunetId) { + requestHeaders.set('x-user-sunetid', user.sunetId); } - if (payload.email) { - requestHeaders.set('x-user-email', payload.email); + if (user.email) { + requestHeaders.set('x-user-email', user.email); } return NextResponse.next({ @@ -63,11 +98,16 @@ export const config = { * * Excludes from middleware processing: * - _next: Next.js internal routes (static files, build assets) - * - api: API routes (intentionally public - see comment above) + * - api: API routes (have individual authorization in route handlers) * - favicon.ico: Browser favicon requests * - * Protected routes must use the /protected/* prefix to require authentication. - * Example: /protected/admin, /protected/dashboard + * Protected routes: + * - /protected/*: Always require authentication + * - /applications/[uuid]: Require app-specific authorization + * - / (dashboard): Require dashboard authorization + * + * Public routes that bypass middleware: + * - /auth/*: Authentication flows (login, test pages) */ - matcher: ['/((?!_next|api|favicon.ico).*)'], + matcher: ['/((?!_next|api|favicon.ico|auth).*)'], }; \ No newline at end of file