From ae96015ce3d92c1a57a2a4898a740867834c340f Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 13:45:05 -0800 Subject: [PATCH 01/19] ACHOO-146: initial implementation of AuthZ --- .env.example | 11 ++ .github/copilot-instructions.md | 43 ++++++ .gitignore | 3 + README.md | 7 + app/api/acquia/applications/route.ts | 3 + app/api/acquia/views/route.ts | 43 +++--- app/api/acquia/visits/route.ts | 3 + app/applications/[uuid]/page.tsx | 59 ++++++-- docs/SAML.md | 136 +++++++++++++++++++ lib/api-auth.ts | 162 ++++++++++++++++++++++ lib/auth-utils.ts | 194 +++++++++++++++++++++++++++ middleware.ts | 93 ++++++++----- 12 files changed, 699 insertions(+), 58 deletions(-) create mode 100644 lib/api-auth.ts diff --git a/.env.example b/.env.example index adc275c..778ac54 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 91b87e5..8d700e0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -141,6 +141,49 @@ const getAttr = (key: string): string | undefined => { - JWT tokens stored in HTTP-only cookies (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 OR any app) +- `parseAppAccessMappings()` - Parse environment variable mappings + +**Middleware Protection** (`middleware.ts`): +- `/` - Dashboard requires global access OR access to â‰Ĩ1 application +- `/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: 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 417a336..a357788 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..9eb97d5 100644 --- a/app/api/acquia/applications/route.ts +++ b/app/api/acquia/applications/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: any }) => { // console.log('🚀 Applications API Route called'); // Update the API service initialization with better error handling @@ -55,4 +57,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..f6eeb84 100644 --- a/app/api/acquia/views/route.ts +++ b/app/api/acquia/views/route.ts @@ -1,27 +1,29 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-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: any }) => { + 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 +84,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..56e3067 100644 --- a/app/api/acquia/visits/route.ts +++ b/app/api/acquia/visits/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { withApiAuthorization } from '@/lib/api-auth'; export async function GET(request: NextRequest) { + return withApiAuthorization(async (request: NextRequest, context: { user: any }) => { const searchParams = request.nextUrl.searchParams; const subscriptionUuid = searchParams.get('subscriptionUuid'); const from = searchParams.get('from'); @@ -85,4 +87,5 @@ export async function GET(request: NextRequest) { { status: 500 } ); } + })(request); } diff --git a/app/applications/[uuid]/page.tsx b/app/applications/[uuid]/page.tsx index 339d86d..729cc95 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/docs/SAML.md b/docs/SAML.md index ea18907..027e7be 100644 --- a/docs/SAML.md +++ b/docs/SAML.md @@ -519,6 +519,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 OR any app) +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: 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 205a8b5..c9ac0ee 100644 --- a/lib/auth-utils.ts +++ b/lib/auth-utils.ts @@ -1,5 +1,199 @@ import { getJWTCookieName, type SamlUser } from '@/lib/jwt-auth' +/** + * Authorization utilities for CHURRO application + * + * Implements two-tier access control: + * 1. Global access via eduPersonEntitlement (e.g., 'uit:sws') + * 2. Per-application access via uid mappings + */ + +/** + * Get authorized eduPersonEntitlement values from environment variables + * Format: CHURRO_GLOBAL_ENTITLEMENTS=uit:sws,other:entitlement + */ +export function getAuthorizedEntitlements(): string[] { + const entitlements = process.env.CHURRO_GLOBAL_ENTITLEMENTS + if (!entitlements) { + return [] + } + + return entitlements + .split(',') + .map(e => e.trim()) + .filter(e => e.length > 0) +} + +/** + * Parse per-application UID mappings from environment variables + * Format: CHURRO_APP_ACCESS=uuid1:uid1,uid2;uuid2:uid3,uid4 + * Returns Map> + */ +export function parseAppAccessMappings(): Map> { + const mappings = new Map>() + const accessString = process.env.CHURRO_APP_ACCESS + + if (!accessString) { + return mappings + } + + try { + // Split by semicolon to get app mappings: "uuid1:uid1,uid2;uuid2:uid3,uid4" + const appMappings = accessString.split(';') + + for (const mapping of appMappings) { + const [appUuid, uidsString] = mapping.split(':') + + if (appUuid && uidsString) { + const uids = uidsString + .split(',') + .map(uid => uid.trim()) + .filter(uid => uid.length > 0) + + mappings.set(appUuid.trim(), new Set(uids)) + } + } + } catch (error) { + console.warn('Failed to parse CHURRO_APP_ACCESS environment variable:', error) + } + + return mappings +} + +/** + * Check if user has global access to the application + * @param user SAML user object + * @returns true if user has global access via eduPersonEntitlement + */ +export function hasGlobalAccess(user: SamlUser): boolean { + const authorizedEntitlements = getAuthorizedEntitlements() + + if (authorizedEntitlements.length === 0) { + // No global entitlements configured - deny access + return false + } + + if (!user.eduPersonEntitlement) { + // User has no entitlements + return false + } + + // User may have multiple entitlements, check if any match + const userEntitlements = Array.isArray(user.eduPersonEntitlement) + ? user.eduPersonEntitlement + : [user.eduPersonEntitlement] + + return userEntitlements.some(entitlement => + authorizedEntitlements.includes(entitlement) + ) +} + +/** + * Check if user has access to a specific application + * @param user SAML user object + * @param applicationUuid Application UUID to check access for + * @returns true if user has access (either global or per-app) + */ +export function hasApplicationAccess(user: SamlUser, applicationUuid: string): boolean { + // First check global access + if (hasGlobalAccess(user)) { + return true + } + + // Check per-application access + const appMappings = parseAppAccessMappings() + const authorizedUids = appMappings.get(applicationUuid) + + if (!authorizedUids || authorizedUids.size === 0) { + // No specific access configured for this app + return false + } + + if (!user.sunetId) { + // User has no SUNet ID + return false + } + + return authorizedUids.has(user.sunetId) +} + +/** + * Check if user can access the main dashboard + * For now, this requires either global access OR access to at least one application + * @param user SAML user object + * @returns true if user can access dashboard + */ +export function hasDashboardAccess(user: SamlUser): boolean { + // Global access grants dashboard access + if (hasGlobalAccess(user)) { + return true + } + + // Check if user has access to any application + const appMappings = parseAppAccessMappings() + + if (!user.sunetId) { + return false + } + + // Check if user's UID appears in any application mapping + let hasAnyAccess = false; + appMappings.forEach((authorizedUids) => { + if (authorizedUids.has(user.sunetId!)) { + hasAnyAccess = true; + } + }); + if (hasAnyAccess) { + return true; + } + + return false +} + +/** + * Get list of application UUIDs the user has access to + * @param user SAML user object + * @returns Array of application UUIDs user can access (empty array = all apps for global users) + */ +export function getUserApplicationAccess(user: SamlUser): string[] { + // Global access grants access to all apps + if (hasGlobalAccess(user)) { + return [] // Empty array means "all apps" for global users + } + + if (!user.sunetId) { + return [] + } + + const appMappings = parseAppAccessMappings() + const accessibleApps: string[] = [] + + appMappings.forEach((authorizedUids, appUuid) => { + if (authorizedUids.has(user.sunetId!)) { + accessibleApps.push(appUuid); + } + }); + + return accessibleApps +} + +/** + * Authorization debugging helper + * @param user SAML user object + * @returns Object with authorization details for debugging + */ +export function getAuthorizationDebugInfo(user: SamlUser) { + return { + userId: user.sunetId || user.id, + userEntitlements: user.eduPersonEntitlement, + hasGlobalAccess: hasGlobalAccess(user), + hasDashboardAccess: hasDashboardAccess(user), + accessibleApps: getUserApplicationAccess(user), + configuredEntitlements: getAuthorizedEntitlements(), + configuredAppMappings: Object.fromEntries(parseAppAccessMappings()) + } +} + /** * Get the current authenticated user from cookies (server-side) * For use in Server Components and API routes diff --git a/middleware.ts b/middleware.ts index 422c200..adc160d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,56 +1,84 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { verifyJWT, getJWTCookieName } from '@/lib/jwt-auth'; +import { hasDashboardAccess, hasApplicationAccess } from '@/lib/auth-utils'; /** - * Middleware for JWT authentication + * Middleware for JWT authentication and authorization * - * DESIGN DECISION: Authentication (AuthN) vs Authorization (AuthZ) + * IMPLEMENTATION: Authentication (AuthN) + Authorization (AuthZ) * - * This middleware currently implements AUTHENTICATION ONLY: + * This middleware implements both AUTHENTICATION and AUTHORIZATION: * - Verifies user identity via JWT tokens from SAML SSO - * - Protects routes with the /protected/* prefix - * - Adds user identity headers for authenticated requests + * - 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 if user has global access OR access to at least one app * - * 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) { const token = request.cookies.get(getJWTCookieName())?.value; + const pathname = request.nextUrl.pathname; - // For protected routes, check authentication - const isProtectedRoute = request.nextUrl.pathname.startsWith('/protected'); + // Check if this is a protected route that requires authentication + const isProtectedRoute = pathname.startsWith('/protected'); - if (isProtectedRoute) { + // Check if this is an application detail page that requires authorization + const isApplicationRoute = pathname.match(/^\/applications\/([a-f0-9-]{36})$/); + + // Check if this is the dashboard that requires authorization + const isDashboardRoute = pathname === '/' || pathname.startsWith('/dashboard'); + + // For routes requiring authentication/authorization + if (isProtectedRoute || isApplicationRoute || isDashboardRoute) { if (!token) { // No token, redirect to SAML login return NextResponse.redirect(new URL('/api/saml/login', request.url)); } // Verify the JWT token - const payload = await verifyJWT(token); - if (!payload) { + const user = await verifyJWT(token); + if (!user) { // Invalid token, redirect to SAML login return NextResponse.redirect(new URL('/api/saml/login', request.url)); } - // Token is valid, add user info to request headers for downstream use + // Authorization checks + if (isApplicationRoute) { + const appUuid = isApplicationRoute[1]; + if (!hasApplicationAccess(user, appUuid)) { + // User doesn't have access to this specific application + return NextResponse.json( + { error: 'Access denied. You do not have permission to view this application.' }, + { status: 403 } + ); + } + } else if (isDashboardRoute) { + if (!hasDashboardAccess(user)) { + // User doesn't have access to dashboard + return NextResponse.json( + { error: 'Access denied. You do not have permission to access this application.' }, + { status: 403 } + ); + } + } + + // Token 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({ @@ -70,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 From 54d759937e014dcb3ff41ddbba961a1e59e68eb0 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 14:13:28 -0800 Subject: [PATCH 02/19] Update logic to block Dashboard access for application-level users --- app/api/auth/logout/route.ts | 13 +++++++++++- app/auth/test/page.tsx | 2 +- lib/auth-utils.ts | 38 ++++++------------------------------ middleware.ts | 10 +++++++--- 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 42625c6..93d6038 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -8,12 +8,23 @@ 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(getJWTCookieName()) - // 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/auth/test/page.tsx b/app/auth/test/page.tsx index cbe4424..d35138b 100644 --- a/app/auth/test/page.tsx +++ b/app/auth/test/page.tsx @@ -53,7 +53,7 @@ function AuthTestContent() { } const handleLogout = () => { - window.location.href = '/api/auth/logout' + window.location.href = '/api/auth/logout?redirectTo=/auth/test' } if (loading) { diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts index c9ac0ee..c6ed308 100644 --- a/lib/auth-utils.ts +++ b/lib/auth-utils.ts @@ -86,9 +86,7 @@ export function hasGlobalAccess(user: SamlUser): boolean { return userEntitlements.some(entitlement => authorizedEntitlements.includes(entitlement) ) -} - -/** +}/** * Check if user has access to a specific application * @param user SAML user object * @param applicationUuid Application UUID to check access for @@ -119,38 +117,14 @@ export function hasApplicationAccess(user: SamlUser, applicationUuid: string): b /** * Check if user can access the main dashboard - * For now, this requires either global access OR access to at least one application + * Dashboard access requires global access only - per-app users should go directly to their apps * @param user SAML user object - * @returns true if user can access dashboard + * @returns true if user can access dashboard (global access only) */ export function hasDashboardAccess(user: SamlUser): boolean { - // Global access grants dashboard access - if (hasGlobalAccess(user)) { - return true - } - - // Check if user has access to any application - const appMappings = parseAppAccessMappings() - - if (!user.sunetId) { - return false - } - - // Check if user's UID appears in any application mapping - let hasAnyAccess = false; - appMappings.forEach((authorizedUids) => { - if (authorizedUids.has(user.sunetId!)) { - hasAnyAccess = true; - } - }); - if (hasAnyAccess) { - return true; - } - - return false -} - -/** + // Only users with global access can see the dashboard + return hasGlobalAccess(user) +}/** * Get list of application UUIDs the user has access to * @param user SAML user object * @returns Array of application UUIDs user can access (empty array = all apps for global users) diff --git a/middleware.ts b/middleware.ts index adc160d..5e9f439 100644 --- a/middleware.ts +++ b/middleware.ts @@ -17,7 +17,8 @@ import { hasDashboardAccess, hasApplicationAccess } from '@/lib/auth-utils'; * 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 if user has global access OR access to at least one app + * 3. Dashboard Access: Granted only to users with global access + * (per-app users should go directly to their authorized applications) * * API routes (/api/*) are intentionally NOT protected in middleware because: * - API-level authorization is implemented in individual route handlers @@ -37,8 +38,11 @@ export async function middleware(request: NextRequest) { // Check if this is the dashboard that requires authorization const isDashboardRoute = pathname === '/' || pathname.startsWith('/dashboard'); - // For routes requiring authentication/authorization - if (isProtectedRoute || isApplicationRoute || isDashboardRoute) { + // Exclude auth test pages from all protection + const isAuthTestRoute = pathname.startsWith('/auth/'); + + // For routes requiring authentication/authorization (excluding auth test routes) + if (!isAuthTestRoute && (isProtectedRoute || isApplicationRoute || isDashboardRoute)) { if (!token) { // No token, redirect to SAML login return NextResponse.redirect(new URL('/api/saml/login', request.url)); From ded1b2435a62786aa66b31e0f5716441185f9840 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 14:29:48 -0800 Subject: [PATCH 03/19] fixup! npm update next eslint-config-next --- package-lock.json | 78 +++++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddab3f3..ddfe34e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -705,9 +705,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", - "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", + "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -721,9 +721,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", - "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -737,9 +737,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", - "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -753,9 +753,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", - "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -769,9 +769,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", - "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -785,9 +785,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", - "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -801,9 +801,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", - "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -817,9 +817,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", - "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -833,9 +833,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", - "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -4905,12 +4905,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", - "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", + "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "license": "MIT", "dependencies": { - "@next/env": "15.5.2", + "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -4923,14 +4923,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.2", - "@next/swc-darwin-x64": "15.5.2", - "@next/swc-linux-arm64-gnu": "15.5.2", - "@next/swc-linux-arm64-musl": "15.5.2", - "@next/swc-linux-x64-gnu": "15.5.2", - "@next/swc-linux-x64-musl": "15.5.2", - "@next/swc-win32-arm64-msvc": "15.5.2", - "@next/swc-win32-x64-msvc": "15.5.2", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { From 66148b657169e469c7df1af709e42d001223a3a1 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:11:25 -0800 Subject: [PATCH 04/19] update to handle both POST and GET handlers --- app/api/saml/acs/route.ts | 179 +++++++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 69 deletions(-) diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index 1cb0e4c..c4d9431 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -4,92 +4,133 @@ import { generateJWT, getJWTCookieName, getSecureCookieOptions, type SamlUser } import { getBaseUrl } from '@/lib/url-utils' import { cookies } from 'next/headers' -export async function POST(request: NextRequest) { - try { - const formData = await request.formData() - const samlResponse = formData.get('SAMLResponse') as string +/** + * Common SAML response processing logic for both POST and GET handlers + */ +async function processSamlResponse(request: NextRequest, samlResponse: string) { + console.log('🔍 Processing SAML response with @node-saml/node-saml...') - if (!samlResponse) { - throw new Error('No SAML response received') + const { profile } = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse }) + + if (!profile) { + throw new Error('No profile returned from SAML response') + } + + console.log('✅ SAML validation succeeded!') + console.log('📋 Profile summary:', { + nameID: profile.nameID, + email: profile.email, + sunetId: profile.sunetId, + attributes: Object.keys(profile.attributes || {}), + }) + + const attributes = (profile.attributes || {}) as Record + + // Helper function to get attribute value (handles both single values and arrays) + const getAttr = (key: string): string | undefined => { + const value = attributes[key] + if (Array.isArray(value)) { + return value[0] as string } + return value as string | undefined + } - console.log('🔍 Processing SAML response with @node-saml/node-saml...') + const user: SamlUser = { + id: profile.nameID || 'unknown-id', + + // Core Stanford Identity + sunetId: getAttr('urn:oid:0.9.2342.19200300.100.1.1'), + email: getAttr('urn:oid:0.9.2342.19200300.100.1.3'), + eduPersonPrincipalName: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.6'), + + // Name + firstName: getAttr('urn:oid:2.5.4.42'), + lastName: getAttr('urn:oid:2.5.4.4'), + displayName: getAttr('urn:oid:2.16.840.1.113730.3.1.241'), + name: getAttr('urn:oid:2.16.840.1.113730.3.1.241') || + `${getAttr('urn:oid:2.5.4.42') || ''} ${getAttr('urn:oid:2.5.4.4') || ''}`.trim() || + 'Stanford User', + + // Affiliations + eduPersonAffiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.1'), + eduPersonScopedAffiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.9'), + suAffiliation: getAttr('suAffiliation'), + affiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.1') || getAttr('suAffiliation'), + + // Other + eduPersonEntitlement: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.7'), + eduPersonOrcid: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.16'), + subjectId: getAttr('urn:oasis:names:tc:SAML:attribute:subject-id'), + pairwiseId: getAttr('urn:oasis:names:tc:SAML:attribute:pairwise-id'), + + authenticationTime: new Date().toISOString(), + allAttributes: attributes, + } - const { profile } = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse }) + console.log('✅ Successfully parsed user:', user.sunetId || user.email || user.id) - if (!profile) { - throw new Error('No profile returned from SAML response') - } + // Generate JWT token from the SAML profile + const jwtToken = await generateJWT(user) - console.log('✅ SAML validation succeeded!') - console.log('📋 Profile summary:', { - nameID: profile.nameID, - email: profile.email, - sunetId: profile.sunetId, - attributes: Object.keys(profile.attributes || {}), - }) - - const attributes = (profile.attributes || {}) as Record - - // Helper function to get attribute value (handles both single values and arrays) - const getAttr = (key: string): string | undefined => { - const value = attributes[key] - if (Array.isArray(value)) { - return value[0] as string - } - return value as string | undefined - } + // Set the JWT as a secure HTTP-only cookie + const cookieStore = await cookies() + cookieStore.set(getJWTCookieName(), jwtToken, getSecureCookieOptions()) - const user: SamlUser = { - id: profile.nameID || 'unknown-id', - - // Core Stanford Identity - sunetId: getAttr('urn:oid:0.9.2342.19200300.100.1.1'), - email: getAttr('urn:oid:0.9.2342.19200300.100.1.3'), - eduPersonPrincipalName: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.6'), - - // Name - firstName: getAttr('urn:oid:2.5.4.42'), - lastName: getAttr('urn:oid:2.5.4.4'), - displayName: getAttr('urn:oid:2.16.840.1.113730.3.1.241'), - name: getAttr('urn:oid:2.16.840.1.113730.3.1.241') || - `${getAttr('urn:oid:2.5.4.42') || ''} ${getAttr('urn:oid:2.5.4.4') || ''}`.trim() || - 'Stanford User', - - // Affiliations - eduPersonAffiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.1'), - eduPersonScopedAffiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.9'), - suAffiliation: getAttr('suAffiliation'), - affiliation: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.1') || getAttr('suAffiliation'), - - // Other - eduPersonEntitlement: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.7'), - eduPersonOrcid: getAttr('urn:oid:1.3.6.1.4.1.5923.1.1.1.16'), - subjectId: getAttr('urn:oasis:names:tc:SAML:attribute:subject-id'), - pairwiseId: getAttr('urn:oasis:names:tc:SAML:attribute:pairwise-id'), - - authenticationTime: new Date().toISOString(), - allAttributes: attributes, - } + // Redirect to the application (or a relay state if available) + const baseUrl = getBaseUrl(request) + const redirectUrl = new URL('/auth/test', baseUrl) + redirectUrl.searchParams.set('saml_success', 'true') - console.log('✅ Successfully parsed user:', user.sunetId || user.email || user.id) + return Response.redirect(redirectUrl.toString(), 302) +} - // Generate JWT token from the SAML profile - const jwtToken = await generateJWT(user) +/** + * Handle SAML responses via HTTP-POST binding (form data) + */ +/** + * Handle SAML responses via HTTP-POST binding (form data) + */ +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const samlResponse = formData.get('SAMLResponse') as string + + if (!samlResponse) { + throw new Error('No SAML response received in form data') + } - // Set the JWT as a secure HTTP-only cookie - const cookieStore = await cookies() - cookieStore.set(getJWTCookieName(), jwtToken, getSecureCookieOptions()) + console.log('📨 POST: Processing SAML response from form data') + return await processSamlResponse(request, samlResponse) + + } catch (error) { + console.error('❌ SAML POST callback error:', error) + console.error('❌ Error stack:', error instanceof Error ? error.stack : 'No stack') - // Redirect to the application (or a relay state if available) const baseUrl = getBaseUrl(request) const redirectUrl = new URL('/auth/test', baseUrl) - redirectUrl.searchParams.set('saml_success', 'true') + redirectUrl.searchParams.set('saml_error', String(error)) return Response.redirect(redirectUrl.toString(), 302) + } +} + +/** + * Handle SAML responses via HTTP-Redirect binding (query parameters) + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const samlResponse = searchParams.get('SAMLResponse') + + if (!samlResponse) { + throw new Error('No SAML response received in query parameters') + } + + console.log('🔗 GET: Processing SAML response from query parameters') + return await processSamlResponse(request, samlResponse) } catch (error) { - console.error('❌ SAML callback error:', error) + console.error('❌ SAML GET callback error:', error) console.error('❌ Error stack:', error instanceof Error ? error.stack : 'No stack') const baseUrl = getBaseUrl(request) From 70d54fda2a85de8f8eee7443c80263a7fd166438 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:16:32 -0800 Subject: [PATCH 05/19] fixup! debug --- app/api/saml/acs/route.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index c4d9431..1df1ac6 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -120,10 +120,29 @@ export async function POST(request: NextRequest) { export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) + + // Debug: Log all query parameters + console.log('🔍 GET request query parameters:') + const allParams: string[] = [] + searchParams.forEach((value, key) => { + console.log(` ${key}: ${value?.substring(0, 100)}${value?.length > 100 ? '...' : ''}`) + allParams.push(key) + }) + const samlResponse = searchParams.get('SAMLResponse') if (!samlResponse) { - throw new Error('No SAML response received in query parameters') + // Check for alternative parameter names that Stanford might use + const altResponse = searchParams.get('samlResponse') || + searchParams.get('Response') || + searchParams.get('saml_response') + + if (altResponse) { + console.log('🔗 GET: Found SAML response with alternative parameter name') + return await processSamlResponse(request, altResponse) + } + + throw new Error(`No SAML response received in query parameters. Available parameters: ${allParams.join(', ')}`) } console.log('🔗 GET: Processing SAML response from query parameters') From 732e03c46cf402fa82bcd972b0f2e4650160df7e Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:26:36 -0800 Subject: [PATCH 06/19] fixup! debug: add vercel.json to disable auth on API endpoint --- vercel.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..1c16918 --- /dev/null +++ b/vercel.json @@ -0,0 +1,7 @@ +{ + "functions": { + "app/api/saml/**": { + "excludeFromAuth": true + } + } +} \ No newline at end of file From 1f3a8f5397ea3040d909409793198457843586cb Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:31:20 -0800 Subject: [PATCH 07/19] fixup! revert --- vercel.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 vercel.json diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 1c16918..0000000 --- a/vercel.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "functions": { - "app/api/saml/**": { - "excludeFromAuth": true - } - } -} \ No newline at end of file From d2507a9c73adfe9607f3e7e5cd113c5b73c5a995 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Fri, 5 Dec 2025 15:36:53 -0800 Subject: [PATCH 08/19] fixup! WIP --- app/api/saml/acs/route.ts | 46 --------------------------------------- 1 file changed, 46 deletions(-) diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index 1df1ac6..217ec4b 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -110,52 +110,6 @@ export async function POST(request: NextRequest) { const redirectUrl = new URL('/auth/test', baseUrl) redirectUrl.searchParams.set('saml_error', String(error)) - return Response.redirect(redirectUrl.toString(), 302) - } -} - -/** - * Handle SAML responses via HTTP-Redirect binding (query parameters) - */ -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url) - - // Debug: Log all query parameters - console.log('🔍 GET request query parameters:') - const allParams: string[] = [] - searchParams.forEach((value, key) => { - console.log(` ${key}: ${value?.substring(0, 100)}${value?.length > 100 ? '...' : ''}`) - allParams.push(key) - }) - - const samlResponse = searchParams.get('SAMLResponse') - - if (!samlResponse) { - // Check for alternative parameter names that Stanford might use - const altResponse = searchParams.get('samlResponse') || - searchParams.get('Response') || - searchParams.get('saml_response') - - if (altResponse) { - console.log('🔗 GET: Found SAML response with alternative parameter name') - return await processSamlResponse(request, altResponse) - } - - throw new Error(`No SAML response received in query parameters. Available parameters: ${allParams.join(', ')}`) - } - - console.log('🔗 GET: Processing SAML response from query parameters') - return await processSamlResponse(request, samlResponse) - - } catch (error) { - console.error('❌ SAML GET callback error:', error) - console.error('❌ Error stack:', error instanceof Error ? error.stack : 'No stack') - - const baseUrl = getBaseUrl(request) - const redirectUrl = new URL('/auth/test', baseUrl) - redirectUrl.searchParams.set('saml_error', String(error)) - return Response.redirect(redirectUrl.toString(), 302) } } \ No newline at end of file From 384d45ca0c03def8507119c5b30f5cc538a78987 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Wed, 10 Dec 2025 15:16:08 -0800 Subject: [PATCH 09/19] Update AuthZ for applications page --- app/applications/page.tsx | 102 +++++++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/app/applications/page.tsx b/app/applications/page.tsx index e6421d2..66f1e97 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -1,47 +1,64 @@ import React from 'react'; +import AcquiaApiServiceFixed from '@/lib/acquia-api'; +import { getCurrentUser, hasGlobalAccess, hasApplicationAccess } from '@/lib/auth-utils'; +import { redirect } from 'next/navigation'; // 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() : [], + // Check authentication first + const user = await getCurrentUser(); + if (!user) { + redirect('/api/saml/login'); + } + + // Initialize API service + const apiService = new AcquiaApiServiceFixed({ + baseUrl: process.env.ACQUIA_API_BASE_URL || 'https://cloud.acquia.com/api', + authUrl: process.env.ACQUIA_AUTH_BASE_URL || 'https://accounts.acquia.com/api', + apiKey: process.env.ACQUIA_API_KEY!, + apiSecret: process.env.ACQUIA_API_SECRET!, + }); + + // Fetch all applications first + const [allApps, views, visits] = await Promise.all([ + apiService.getApplications(), + apiService.getViewsDataByApplication(process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID!), + apiService.getVisitsDataByApplication(process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID!), ]); - // 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 }; + // Filter applications based on user permissions + const apps = hasGlobalAccess(user) + ? allApps || [] + : (allApps || []).filter(app => hasApplicationAccess(user, app.uuid)); + + return { + apps, + views: views || [], + visits: visits || [], + user + }; } -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])); +function getAppStats(apps: any[], views: any[], visits: any[]) { + // Aggregate views/visits by app uuid (sum across all dates) + const viewsByApp: Record = {}; + const visitsByApp: Record = {}; + + views.forEach(v => { + const uuid = v.applicationUuid || v.uuid; + viewsByApp[uuid] = (viewsByApp[uuid] || 0) + v.views; + }); + + visits.forEach(v => { + const uuid = v.applicationUuid || v.uuid; + visitsByApp[uuid] = (visitsByApp[uuid] || 0) + v.visits; + }); + // 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 => ({ @@ -54,9 +71,26 @@ function getAppStats(apps: any[], views: { map: (arg0: (v: any) => any[]) => Ite } export default async function ApplicationsPage() { - const { apps, views, visits } = await fetchData(); + const { apps, views, visits, user } = await fetchData(); const stats = getAppStats(apps, views, visits); + if (stats.length === 0) { + return ( +
+

Application Views & Visits

+
+

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

From 44b6b9f25c993b4c96894423f237f3e550f2e8ca Mon Sep 17 00:00:00 2001 From: John Bickar Date: Wed, 10 Dec 2025 15:57:24 -0800 Subject: [PATCH 10/19] Fixes to applications overview page --- app/applications/page.tsx | 432 +++++++++++++++++++++++++++++++------- 1 file changed, 352 insertions(+), 80 deletions(-) diff --git a/app/applications/page.tsx b/app/applications/page.tsx index 66f1e97..e7d34d8 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -1,89 +1,311 @@ -import React from 'react'; -import AcquiaApiServiceFixed from '@/lib/acquia-api'; -import { getCurrentUser, hasGlobalAccess, hasApplicationAccess } from '@/lib/auth-utils'; -import { redirect } from 'next/navigation'; - -// Force dynamic rendering - don't try to pre-render at build time -export const dynamic = 'force-dynamic'; - -async function fetchData() { - // Check authentication first - const user = await getCurrentUser(); - if (!user) { - redirect('/api/saml/login'); - } +'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]; - // Initialize API service - const apiService = new AcquiaApiServiceFixed({ - baseUrl: process.env.ACQUIA_API_BASE_URL || 'https://cloud.acquia.com/api', - authUrl: process.env.ACQUIA_AUTH_BASE_URL || 'https://accounts.acquia.com/api', - apiKey: process.env.ACQUIA_API_KEY!, - apiSecret: process.env.ACQUIA_API_SECRET!, + // Get formatted month name for display + const monthName = previousMonth.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric' }); - // Fetch all applications first - const [allApps, views, visits] = await Promise.all([ - apiService.getApplications(), - apiService.getViewsDataByApplication(process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID!), - apiService.getVisitsDataByApplication(process.env.NEXT_PUBLIC_ACQUIA_SUBSCRIPTION_UUID!), - ]); - - // Filter applications based on user permissions - const apps = hasGlobalAccess(user) - ? allApps || [] - : (allApps || []).filter(app => hasApplicationAccess(user, app.uuid)); - - return { - apps, - views: views || [], - visits: visits || [], - user - }; + 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: any[], visits: any[]) { +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 = {}; - views.forEach(v => { - const uuid = v.applicationUuid || v.uuid; - viewsByApp[uuid] = (viewsByApp[uuid] || 0) + v.views; + // Ensure views and visits are arrays + const viewsArray = Array.isArray(views) ? views : []; + const visitsArray = Array.isArray(visits) ? visits : []; + + console.log('🔍 Processing views data:', viewsArray.length, 'items'); + console.log('🔍 Processing visits data:', visitsArray.length, 'items'); + if (viewsArray.length > 0) { + console.log('📊 Sample views item:', JSON.stringify(viewsArray[0], null, 2)); + console.log('📊 Views item keys:', Object.keys(viewsArray[0])); + } + if (visitsArray.length > 0) { + console.log('📊 Sample visits item:', JSON.stringify(visitsArray[0], null, 2)); + console.log('📊 Visits item keys:', Object.keys(visitsArray[0])); + } + + viewsArray.forEach((v, index) => { + const uuid = v.applicationUuid; + const viewCount = v.views || 0; + if (index < 3) { + console.log(`📈 Processing views item ${index}:`, { uuid, viewCount, hasUuid: !!uuid, hasViews: !!v.views, item: JSON.stringify(v, null, 2) }); + } + if (uuid && viewCount > 0) { + viewsByApp[uuid] = (viewsByApp[uuid] || 0) + viewCount; + console.log(`📈 Views accumulated: ${uuid} = ${viewsByApp[uuid]} (added ${viewCount})`); + } }); - visits.forEach(v => { - const uuid = v.applicationUuid || v.uuid; - visitsByApp[uuid] = (visitsByApp[uuid] || 0) + v.visits; + visitsArray.forEach((v, index) => { + const uuid = v.applicationUuid; + const visitCount = v.visits || 0; + if (index < 3) { + console.log(`đŸ‘Ĩ Processing visits item ${index}:`, { uuid, visitCount, hasUuid: !!uuid, hasVisits: !!v.visits, item: JSON.stringify(v, null, 2) }); + } + if (uuid && visitCount > 0) { + visitsByApp[uuid] = (visitsByApp[uuid] || 0) + visitCount; + console.log(`đŸ‘Ĩ Visits accumulated: ${uuid} = ${visitsByApp[uuid]} (added ${visitCount})`); + } }); // Calculate totals const totalViews = Object.values(viewsByApp).reduce((sum, v) => sum + v, 0); const totalVisits = Object.values(visitsByApp).reduce((sum, v) => sum + v, 0); + console.log('📊 Aggregated views by app:', JSON.stringify(viewsByApp, null, 2)); + console.log('📊 Aggregated visits by app:', JSON.stringify(visitsByApp, null, 2)); + console.log('📊 Total views:', totalViews); + console.log('📊 Total visits:', totalVisits); + console.log('📊 Apps to process:', apps.map(a => `${a.name} (${a.uuid})`)); + // 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, })); + + console.log('📈 Final stats for apps:', result.map(r => `${r.name}: ${r.views} views, ${r.visits} visits`)); + return result; } -export default async function ApplicationsPage() { - const { apps, views, visits, user } = await fetchData(); - const stats = getAppStats(apps, views, visits); +export default function ApplicationsPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [applications, setApplications] = useState([]); + 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 and apply user permissions + 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) + ); + + setApplications(sortedApps); + + // 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() + ]); + + console.log('🌐 Raw views API response:', { + type: typeof viewsData, + isArray: Array.isArray(viewsData), + length: viewsData?.length, + keys: viewsData && typeof viewsData === 'object' ? Object.keys(viewsData) : 'N/A', + sample: viewsData + }); + + console.log('🌐 Raw visits API response:', { + type: typeof visitsData, + isArray: Array.isArray(visitsData), + length: visitsData?.length, + keys: visitsData && typeof visitsData === 'object' ? Object.keys(visitsData) : 'N/A', + sample: visitsData + }); + + // 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 : []; + + console.log('🔧 Extracted arrays:', { + viewsLength: viewsArray.length, + visitsLength: visitsArray.length + }); + + // 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

-
-

No Applications Available

-

+

+

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'}

@@ -92,32 +314,82 @@ export default async function ApplicationsPage() { } 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 From a249d7639b4f279cbd0f022c9efcccc7b8930d09 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Wed, 10 Dec 2025 16:20:47 -0800 Subject: [PATCH 11/19] Fixes from Copilot PR review --- .github/copilot-instructions.md | 4 +-- app/api/acquia/applications/route.ts | 3 +- app/api/acquia/views/route.ts | 3 +- app/api/acquia/visits/route.ts | 3 +- app/applications/page.tsx | 54 ++-------------------------- docs/SAML.md | 2 +- lib/auth-utils.ts | 42 ++++++++++++++++++++-- 7 files changed, 50 insertions(+), 61 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 35e9e45..381e0e7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -154,11 +154,11 @@ const getAttr = (key: string): string | undefined => { **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 OR any app) +- `hasDashboardAccess(user)` - Check if can access dashboard (global access only) - `parseAppAccessMappings()` - Parse environment variable mappings **Middleware Protection** (`middleware.ts`): -- `/` - Dashboard requires global access OR access to â‰Ĩ1 application +- `/` - Dashboard requires global access only - `/applications/[uuid]` - Requires global access OR specific application access - Returns 403 with clear error messages for unauthorized access diff --git a/app/api/acquia/applications/route.ts b/app/api/acquia/applications/route.ts index 9eb97d5..7765440 100644 --- a/app/api/acquia/applications/route.ts +++ b/app/api/acquia/applications/route.ts @@ -1,9 +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: any }) => { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { // console.log('🚀 Applications API Route called'); // Update the API service initialization with better error handling diff --git a/app/api/acquia/views/route.ts b/app/api/acquia/views/route.ts index f6eeb84..d6a71e4 100644 --- a/app/api/acquia/views/route.ts +++ b/app/api/acquia/views/route.ts @@ -1,9 +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: any }) => { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { const searchParams = request.nextUrl.searchParams; const subscriptionUuid = searchParams.get('subscriptionUuid'); const from = searchParams.get('from'); diff --git a/app/api/acquia/visits/route.ts b/app/api/acquia/visits/route.ts index 56e3067..5a85a2d 100644 --- a/app/api/acquia/visits/route.ts +++ b/app/api/acquia/visits/route.ts @@ -1,9 +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: any }) => { + return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => { const searchParams = request.nextUrl.searchParams; const subscriptionUuid = searchParams.get('subscriptionUuid'); const from = searchParams.get('from'); diff --git a/app/applications/page.tsx b/app/applications/page.tsx index e7d34d8..b483a1b 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -50,38 +50,19 @@ function getAppStats(apps: Application[], views: any[], visits: any[]): AppStats const viewsArray = Array.isArray(views) ? views : []; const visitsArray = Array.isArray(visits) ? visits : []; - console.log('🔍 Processing views data:', viewsArray.length, 'items'); - console.log('🔍 Processing visits data:', visitsArray.length, 'items'); - if (viewsArray.length > 0) { - console.log('📊 Sample views item:', JSON.stringify(viewsArray[0], null, 2)); - console.log('📊 Views item keys:', Object.keys(viewsArray[0])); - } - if (visitsArray.length > 0) { - console.log('📊 Sample visits item:', JSON.stringify(visitsArray[0], null, 2)); - console.log('📊 Visits item keys:', Object.keys(visitsArray[0])); - } - - viewsArray.forEach((v, index) => { + viewsArray.forEach((v) => { const uuid = v.applicationUuid; const viewCount = v.views || 0; - if (index < 3) { - console.log(`📈 Processing views item ${index}:`, { uuid, viewCount, hasUuid: !!uuid, hasViews: !!v.views, item: JSON.stringify(v, null, 2) }); - } if (uuid && viewCount > 0) { viewsByApp[uuid] = (viewsByApp[uuid] || 0) + viewCount; - console.log(`📈 Views accumulated: ${uuid} = ${viewsByApp[uuid]} (added ${viewCount})`); } }); - visitsArray.forEach((v, index) => { + visitsArray.forEach((v) => { const uuid = v.applicationUuid; const visitCount = v.visits || 0; - if (index < 3) { - console.log(`đŸ‘Ĩ Processing visits item ${index}:`, { uuid, visitCount, hasUuid: !!uuid, hasVisits: !!v.visits, item: JSON.stringify(v, null, 2) }); - } if (uuid && visitCount > 0) { visitsByApp[uuid] = (visitsByApp[uuid] || 0) + visitCount; - console.log(`đŸ‘Ĩ Visits accumulated: ${uuid} = ${visitsByApp[uuid]} (added ${visitCount})`); } }); @@ -89,12 +70,6 @@ function getAppStats(apps: Application[], views: any[], visits: any[]): AppStats const totalViews = Object.values(viewsByApp).reduce((sum, v) => sum + v, 0); const totalVisits = Object.values(visitsByApp).reduce((sum, v) => sum + v, 0); - console.log('📊 Aggregated views by app:', JSON.stringify(viewsByApp, null, 2)); - console.log('📊 Aggregated visits by app:', JSON.stringify(visitsByApp, null, 2)); - console.log('📊 Total views:', totalViews); - console.log('📊 Total visits:', totalVisits); - console.log('📊 Apps to process:', apps.map(a => `${a.name} (${a.uuid})`)); - // Merge stats const result = apps.map(app => ({ ...app, @@ -104,14 +79,12 @@ function getAppStats(apps: Application[], views: any[], visits: any[]): AppStats visitsPct: totalVisits ? ((visitsByApp[app.uuid] || 0) / totalVisits) * 100 : 0, })); - console.log('📈 Final stats for apps:', result.map(r => `${r.name}: ${r.views} views, ${r.visits} visits`)); return result; } export default function ApplicationsPage() { const router = useRouter(); const [user, setUser] = useState(null); - const [applications, setApplications] = useState([]); const [stats, setStats] = useState([]); const [loading, setLoading] = useState(true); const [loadingMetrics, setLoadingMetrics] = useState(false); @@ -159,8 +132,6 @@ export default function ApplicationsPage() { a.name.localeCompare(b.name) ); - setApplications(sortedApps); - // Initialize stats with just application info (no metrics yet) const initialStats: AppStats[] = sortedApps.map((app: Application) => ({ ...app, @@ -223,22 +194,6 @@ export default function ApplicationsPage() { visitsResponse.json() ]); - console.log('🌐 Raw views API response:', { - type: typeof viewsData, - isArray: Array.isArray(viewsData), - length: viewsData?.length, - keys: viewsData && typeof viewsData === 'object' ? Object.keys(viewsData) : 'N/A', - sample: viewsData - }); - - console.log('🌐 Raw visits API response:', { - type: typeof visitsData, - isArray: Array.isArray(visitsData), - length: visitsData?.length, - keys: visitsData && typeof visitsData === 'object' ? Object.keys(visitsData) : 'N/A', - sample: visitsData - }); - // Handle different response formats - API routes wrap data in { data: [...] } const viewsArray = Array.isArray(viewsData) ? viewsData : Array.isArray(viewsData.data) ? viewsData.data : []; @@ -246,11 +201,6 @@ export default function ApplicationsPage() { const visitsArray = Array.isArray(visitsData) ? visitsData : Array.isArray(visitsData.data) ? visitsData.data : []; - console.log('🔧 Extracted arrays:', { - viewsLength: viewsArray.length, - visitsLength: visitsArray.length - }); - // Calculate final stats const finalStats = getAppStats(apps, viewsArray, visitsArray); setStats(finalStats); diff --git a/docs/SAML.md b/docs/SAML.md index cf0c255..87f6782 100644 --- a/docs/SAML.md +++ b/docs/SAML.md @@ -566,7 +566,7 @@ const canAccessAll = hasGlobalAccess(user); // Check access to specific application const canAccessApp = hasApplicationAccess(user, 'app-uuid-here'); -// Check if user can access dashboard (global OR any app) +// Check if user can access dashboard (global access only) const canAccessDashboard = hasDashboardAccess(user); ``` diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts index 3bb7027..2d7f00b 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 @@ -15,12 +21,19 @@ export async function getCurrentUser(): Promise { /** * 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 } @@ -35,19 +48,42 @@ export function parseAppAccessMappings(): Map> { } } + // 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 { - const globalEntitlements = process.env.CHURRO_GLOBAL_ENTITLEMENTS - if (!globalEntitlements || !user.eduPersonEntitlement) { + if (!user.eduPersonEntitlement) { return false } - const allowedEntitlements = new Set(globalEntitlements.split(',').map(e => e.trim())) + const allowedEntitlements = parseGlobalEntitlements() return allowedEntitlements.has(user.eduPersonEntitlement) } From 00d8f584a0811ce74f01655fbd0999e03e533eee Mon Sep 17 00:00:00 2001 From: John Bickar Date: Wed, 10 Dec 2025 16:27:23 -0800 Subject: [PATCH 12/19] Additional fixes per Copilot PR review. Add error page --- app/api/acquia/applications/route.ts | 18 +++++- app/applications/page.tsx | 2 +- app/error/page.tsx | 95 ++++++++++++++++++++++++++++ lib/auth-utils.ts | 6 +- middleware.ts | 18 +++--- 5 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 app/error/page.tsx diff --git a/app/api/acquia/applications/route.ts b/app/api/acquia/applications/route.ts index 7765440..c09811f 100644 --- a/app/api/acquia/applications/route.ts +++ b/app/api/acquia/applications/route.ts @@ -2,6 +2,7 @@ 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 }) => { @@ -38,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); diff --git a/app/applications/page.tsx b/app/applications/page.tsx index b483a1b..e79ae02 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -123,7 +123,7 @@ export default function ApplicationsPage() { const appsData = await appsResponse.json(); - // Filter out excluded UUIDs and apply user permissions + // 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)); diff --git a/app/error/page.tsx b/app/error/page.tsx new file mode 100644 index 0000000..ab01f73 --- /dev/null +++ b/app/error/page.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useSearchParams, useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +export default function ErrorPage() { + 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.

+
+
+
+ ); +} \ No newline at end of file diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts index 2d7f00b..a35def1 100644 --- a/lib/auth-utils.ts +++ b/lib/auth-utils.ts @@ -43,7 +43,11 @@ export function parseAppAccessMappings(): Map> { for (const appMapping of appMappings) { const [uuid, uidsStr] = appMapping.split(':') if (uuid && uidsStr) { - const uids = new Set(uidsStr.split(',').map(uid => uid.trim())) + const uids = new Set( + uidsStr.split(',') + .map(uid => uid.trim()) + .filter(uid => uid.length > 0) + ) mappings.set(uuid.trim(), uids) } } diff --git a/middleware.ts b/middleware.ts index 49077eb..2fdd11c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -52,18 +52,20 @@ export async function middleware(request: NextRequest) { if (isApplicationRoute && appUuid) { if (!hasApplicationAccess(user, appUuid)) { // User doesn't have access to this specific application - return NextResponse.json( - { error: 'Access denied. You do not have permission to view this application.' }, - { status: 403 } - ); + // 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 - return NextResponse.json( - { error: 'Access denied. You do not have permission to access this application.' }, - { status: 403 } - ); + // 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); } } From 3148a2834396479fb3ee657eed43c68c765040ec Mon Sep 17 00:00:00 2001 From: John Bickar Date: Tue, 16 Dec 2025 13:46:52 -0800 Subject: [PATCH 13/19] fixup! error page --- app/error/page.tsx | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/app/error/page.tsx b/app/error/page.tsx index ab01f73..5c10320 100644 --- a/app/error/page.tsx +++ b/app/error/page.tsx @@ -1,9 +1,9 @@ 'use client'; import { useSearchParams, useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, Suspense } from 'react'; -export default function ErrorPage() { +function ErrorContent() { const searchParams = useSearchParams(); const router = useRouter(); const [errorType, setErrorType] = useState(''); @@ -92,4 +92,37 @@ export default function ErrorPage() {
); +} + +function ErrorFallback() { + return ( +
+
+
+ {/* Error icon */} +
+ + + +
+ +

+ Access Denied +

+ +

+ Loading error details... +

+
+
+
+ ); +} + +export default function ErrorPage() { + return ( + }> + + + ); } \ No newline at end of file From e03ac79f8c4dbdb94afa72e39a61f9739eb5df5d Mon Sep 17 00:00:00 2001 From: John Bickar Date: Tue, 16 Dec 2025 13:52:26 -0800 Subject: [PATCH 14/19] Update doxn to reflect iron-session implementation --- .github/copilot-instructions.md | 30 +++++++++++++------------- docs/SAML.md | 38 ++++++++++++++++----------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 381e0e7..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,7 +138,7 @@ 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 @@ -234,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) @@ -263,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' ``` @@ -307,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/docs/SAML.md b/docs/SAML.md index 87f6782..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 }, @@ -752,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' ``` @@ -789,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 @@ -800,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 From 39580f5070385ad56d548fca19c249766d323497 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Tue, 16 Dec 2025 13:56:36 -0800 Subject: [PATCH 15/19] Re-route user to path initially requested after AuthN --- app/api/saml/acs/route.ts | 27 ++++++++++++++++++++++----- app/api/saml/login/route.ts | 6 +++++- middleware.ts | 6 ++++-- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index a085b91..ae64c7d 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 }) @@ -79,11 +80,26 @@ async function processSamlResponse(request: NextRequest, samlResponse: string) { // - Iron-session provides encryption for enhanced security without added complexity await createSession(user) - // Redirect to the application (or a relay state if available) + // Redirect to the originally requested page (from RelayState) or fallback to dashboard const baseUrl = getBaseUrl(request) - const redirectUrl = new URL('/auth/test', baseUrl) - redirectUrl.searchParams.set('saml_success', 'true') + const returnTo = (profile as any)?.nameQualifier || '/' // RelayState comes through as nameQualifier in some SAML implementations + + // Validate return URL is safe (same origin, no external redirects) + let redirectPath = '/' + if (returnTo && typeof returnTo === 'string') { + try { + const returnUrl = new URL(returnTo, baseUrl) + // Only allow same-origin redirects for security + if (returnUrl.origin === baseUrl) { + redirectPath = returnUrl.pathname + returnUrl.search + } + } catch { + // Invalid URL, fallback to dashboard + redirectPath = '/' + } + } + const redirectUrl = new URL(redirectPath, baseUrl) return Response.redirect(redirectUrl.toString(), 302) } @@ -97,13 +113,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..d35abde 100644 --- a/app/api/saml/login/route.ts +++ b/app/api/saml/login/route.ts @@ -3,7 +3,11 @@ 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') || '/' + + // Use RelayState to preserve the return URL through the SAML flow + const loginUrl = await saml.getAuthorizeUrlAsync(returnTo, '', {}) return NextResponse.redirect(loginUrl) } catch (error) { console.error('Error initiating SAML login:', error) diff --git a/middleware.ts b/middleware.ts index 2fdd11c..eca336f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -44,8 +44,10 @@ export async function middleware(request: NextRequest) { // Verify the session const user = await verifySession(); if (!user) { - // Invalid session, redirect to SAML login - return NextResponse.redirect(new URL('/api/saml/login', request.url)); + // 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); } // Authorization checks From 0f9111d93a32dfac55d8a94ed64dd577e6f84ec0 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Tue, 16 Dec 2025 14:01:10 -0800 Subject: [PATCH 16/19] WIP infinite redirect --- app/api/saml/acs/route.ts | 7 +++++-- middleware.ts | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index ae64c7d..7a8c455 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -78,15 +78,17 @@ async function processSamlResponse(request: NextRequest, samlResponse: string, r // - ~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 originally requested page (from RelayState) or fallback to dashboard const baseUrl = getBaseUrl(request) - const returnTo = (profile as any)?.nameQualifier || '/' // RelayState comes through as nameQualifier in some SAML implementations + const returnTo = relayState || '/' // Use the RelayState parameter passed to this function // Validate return URL is safe (same origin, no external redirects) let redirectPath = '/' - if (returnTo && typeof returnTo === 'string') { + if (returnTo && typeof returnTo === 'string' && returnTo !== '/') { try { const returnUrl = new URL(returnTo, baseUrl) // Only allow same-origin redirects for security @@ -99,6 +101,7 @@ async function processSamlResponse(request: NextRequest, samlResponse: string, r } } + console.log('🔄 Redirecting to:', redirectPath) const redirectUrl = new URL(redirectPath, baseUrl) return Response.redirect(redirectUrl.toString(), 302) } diff --git a/middleware.ts b/middleware.ts index eca336f..d73a3d7 100644 --- a/middleware.ts +++ b/middleware.ts @@ -43,10 +43,17 @@ export async function middleware(request: NextRequest) { if (needsAuth) { // Verify the session const user = await verifySession(); + console.log('🔐 Middleware auth check:', { + path: request.nextUrl.pathname, + hasUser: !!user, + userSunetId: user?.sunetId || 'none' + }); + 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); + console.log('❌ No user session, redirecting to login with returnTo:', request.nextUrl.pathname + request.nextUrl.search); return NextResponse.redirect(loginUrl); } From 4fb7c91d6e3c5442ccf064e5f9ab1516f692be7c Mon Sep 17 00:00:00 2001 From: John Bickar Date: Tue, 16 Dec 2025 15:55:50 -0800 Subject: [PATCH 17/19] WIP create success page for auth --- app/api/saml/acs/route.ts | 29 ++++++++++------------------- app/api/saml/login/route.ts | 6 +++++- app/auth/success/page.tsx | 36 ++++++++++++++++++++++++++++++++++++ middleware.ts | 7 ------- 4 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 app/auth/success/page.tsx diff --git a/app/api/saml/acs/route.ts b/app/api/saml/acs/route.ts index 7a8c455..c55091d 100644 --- a/app/api/saml/acs/route.ts +++ b/app/api/saml/acs/route.ts @@ -82,28 +82,19 @@ async function processSamlResponse(request: NextRequest, samlResponse: string, r await createSession(user) console.log('✅ Session created successfully') - // Redirect to the originally requested page (from RelayState) or fallback to dashboard + // 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 returnTo = relayState || '/' // Use the RelayState parameter passed to this function - - // Validate return URL is safe (same origin, no external redirects) - let redirectPath = '/' - if (returnTo && typeof returnTo === 'string' && returnTo !== '/') { - try { - const returnUrl = new URL(returnTo, baseUrl) - // Only allow same-origin redirects for security - if (returnUrl.origin === baseUrl) { - redirectPath = returnUrl.pathname + returnUrl.search - } - } catch { - // Invalid URL, fallback to dashboard - redirectPath = '/' - } + const returnTo = relayState || '/' + const authSuccessUrl = new URL('/auth/success', baseUrl) + + // Pass the return URL as a query parameter for client-side redirect + if (returnTo !== '/') { + authSuccessUrl.searchParams.set('returnTo', returnTo) } - console.log('🔄 Redirecting to:', redirectPath) - const redirectUrl = new URL(redirectPath, baseUrl) - return Response.redirect(redirectUrl.toString(), 302) + console.log('🔄 Redirecting to auth success page with returnTo:', returnTo) + return Response.redirect(authSuccessUrl.toString(), 302) } /** diff --git a/app/api/saml/login/route.ts b/app/api/saml/login/route.ts index d35abde..526eeb0 100644 --- a/app/api/saml/login/route.ts +++ b/app/api/saml/login/route.ts @@ -5,12 +5,16 @@ export async function GET(request: NextRequest) { try { // 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/auth/success/page.tsx b/app/auth/success/page.tsx new file mode 100644 index 0000000..67335e5 --- /dev/null +++ b/app/auth/success/page.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' + +export default function AuthSuccessPage() { + 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... +

+
+
+ ) +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index d73a3d7..eca336f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -43,17 +43,10 @@ export async function middleware(request: NextRequest) { if (needsAuth) { // Verify the session const user = await verifySession(); - console.log('🔐 Middleware auth check:', { - path: request.nextUrl.pathname, - hasUser: !!user, - userSunetId: user?.sunetId || 'none' - }); - 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); - console.log('❌ No user session, redirecting to login with returnTo:', request.nextUrl.pathname + request.nextUrl.search); return NextResponse.redirect(loginUrl); } From 860db4a48034e0ec23c9a3188235f247cf8cd674 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Tue, 16 Dec 2025 16:02:01 -0800 Subject: [PATCH 18/19] Fix success page build error --- app/auth/success/page.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/app/auth/success/page.tsx b/app/auth/success/page.tsx index 67335e5..748e908 100644 --- a/app/auth/success/page.tsx +++ b/app/auth/success/page.tsx @@ -1,9 +1,9 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -export default function AuthSuccessPage() { +function AuthSuccessContent() { const router = useRouter() const searchParams = useSearchParams() @@ -33,4 +33,25 @@ export default function AuthSuccessPage() {
) +} + +function LoadingFallback() { + return ( +
+
+
+

+ Processing Authentication... +

+
+
+ ) +} + +export default function AuthSuccessPage() { + return ( + }> + + + ) } \ No newline at end of file From 56954bb9ac57c60f2d2566c3beafe543300b8b32 Mon Sep 17 00:00:00 2001 From: John Bickar Date: Tue, 16 Dec 2025 16:21:59 -0800 Subject: [PATCH 19/19] Add authenticated header and logout button --- app/auth/test/page.tsx | 179 +++++++++++++++++------------ app/layout.tsx | 39 +------ components/AuthenticatedHeader.tsx | 96 ++++++++++++++++ components/LogoutButton.tsx | 73 ++++++++++++ 4 files changed, 277 insertions(+), 110 deletions(-) create mode 100644 components/AuthenticatedHeader.tsx create mode 100644 components/LogoutButton.tsx diff --git a/app/auth/test/page.tsx b/app/auth/test/page.tsx index d35138b..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?redirectTo=/auth/test' - } - 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/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