Skip to content
Closed
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
43 changes: 43 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that dashboard access requires "global OR any app" access, but the implementation in hasDashboardAccess only grants access to users with global access. This inconsistency means per-application users will be denied dashboard access despite documentation stating they should have it.

Either update the implementation to allow per-app users to access the dashboard (showing only their authorized applications), or update the documentation to accurately reflect that only global access users can access the dashboard.

Suggested change
- `hasDashboardAccess(user)` - Check if can access dashboard (global OR any app)
- `hasDashboardAccess(user)` - Returns `true` if the user has global access **or** access to at least one application. Per-application users will see only their authorized applications on the dashboard.

Copilot uses AI. Check for mistakes.
- `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:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ next-env.d.ts
# saml
saml-sp.key
saml-sp.crt

# Cache directory, if present
.cache
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions app/api/acquia/applications/route.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation. The code inside the withApiAuthorization callback is not properly indented. The block from line 7 onwards should be indented to match the callback function nesting level for better code readability.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The applications API returns all applications for any authenticated user without filtering based on their permissions. Users with per-application access should only see applications they're authorized to access. The user context from withApiAuthorization is available but not being used to filter results. Consider filtering the applications list based on the user's permissions using getUserApplicationAccess() or hasApplicationAccess().

Copilot uses AI. Check for mistakes.
// console.log('🚀 Applications API Route called');

// Update the API service initialization with better error handling
Expand Down Expand Up @@ -55,4 +57,5 @@ export async function GET(request: NextRequest) {
{ status: 500 }
);
}
})(request);
}
43 changes: 23 additions & 20 deletions app/api/acquia/views/route.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API is protected with withApiAuthorization but doesn't verify that the user has access to the specific application identified by subscriptionUuid. This means any authenticated user can access data for any application. Consider using requireApplicationUuid: true option or manually checking application access using the subscriptionUuid parameter.

Copilot uses AI. Check for mistakes.
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 }
);
}
Comment on lines +6 to +26
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation. The code inside the withApiAuthorization callback is not properly indented, making it harder to read and maintain. The entire block from line 7-86 should be indented to match the callback function nesting level.

Copilot uses AI. Check for mistakes.

if (!process.env.ACQUIA_API_KEY || !process.env.ACQUIA_API_SECRET) {
console.error('❌ Missing required environment variables!');
Expand Down Expand Up @@ -82,4 +84,5 @@ export async function GET(request: NextRequest) {
{ status: 500 }
);
}
})(request);
}
3 changes: 3 additions & 0 deletions app/api/acquia/visits/route.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API is protected with withApiAuthorization but doesn't verify that the user has access to the specific application identified by subscriptionUuid. This means any authenticated user can access data for any application. Consider using requireApplicationUuid: true option or manually checking application access using the subscriptionUuid parameter.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation. The code inside the withApiAuthorization callback is not properly indented. The block from line 7 onwards should be indented to match the callback function nesting level for better code readability.

Copilot uses AI. Check for mistakes.
const searchParams = request.nextUrl.searchParams;
const subscriptionUuid = searchParams.get('subscriptionUuid');
const from = searchParams.get('from');
Expand Down Expand Up @@ -85,4 +87,5 @@ export async function GET(request: NextRequest) {
{ status: 500 }
);
}
})(request);
}
13 changes: 12 additions & 1 deletion app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,22 @@ import { getBaseUrl } from '@/lib/url-utils'
*/
export async function GET(request: NextRequest) {
const cookieStore = await cookies()
const { searchParams } = new URL(request.url)

// Delete the JWT cookie
cookieStore.delete(getSessionCookieName())

// Redirect to home page or login page
// Check if redirect URL is specified (e.g., from test page)
const redirectTo = searchParams.get('redirectTo')
const baseUrl = getBaseUrl(request)

if (redirectTo) {
// Validate that redirect is to a safe path (starts with /)
if (redirectTo.startsWith('/')) {
return NextResponse.redirect(new URL(redirectTo, baseUrl))
Comment on lines +21 to +23
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redirect validation only checks if the path starts with '/', but this is insufficient to prevent open redirect vulnerabilities. An attacker could use URLs like '//evil.com' or '/\evil.com' which start with '/' but redirect to external sites. Use URL parsing and validate that the hostname matches the expected domain, or use a whitelist of allowed redirect paths.

Suggested change
// Validate that redirect is to a safe path (starts with /)
if (redirectTo.startsWith('/')) {
return NextResponse.redirect(new URL(redirectTo, baseUrl))
// Validate that redirectTo is a safe, relative path (no protocol, no hostname, no double slashes, no backslashes)
try {
// Only allow relative paths that start with a single slash and do not contain suspicious patterns
if (
redirectTo.startsWith('/') &&
!redirectTo.startsWith('//') &&
!redirectTo.startsWith('/\\') &&
!redirectTo.includes('\\') &&
!redirectTo.includes('..') // Prevent directory traversal
) {
const url = new URL(redirectTo, baseUrl)
// Ensure the redirect stays on the same origin
if (url.origin === baseUrl) {
return NextResponse.redirect(url)
}
}
} catch (e) {
// Ignore invalid URLs and fall through to default redirect

Copilot uses AI. Check for mistakes.
}
}

// Default redirect to home page
return NextResponse.redirect(new URL('/', baseUrl))
}
59 changes: 51 additions & 8 deletions app/applications/[uuid]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,34 @@ export default function ApplicationDetailPage({ params }: any) {
const [error, setError] = useState<string | null>(null);
const [dailyViews, setDailyViews] = useState<DailyDataPoint[]>([]);
const [dailyVisits, setDailyVisits] = useState<DailyDataPoint[]>([]);
const [authError, setAuthError] = useState<string | null>(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(() => {
const fetchAppName = async () => {
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('');
Expand All @@ -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 (
<div className="min-h-screen p-8">
<div className="max-w-2xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h1 className="text-xl font-semibold text-red-800 mb-2">Access Denied</h1>
<p className="text-red-700 mb-4">{authError}</p>
<p className="text-sm text-red-600">
If you believe you should have access to this application, please contact your administrator.
</p>
<div className="mt-4">
<a
href="/"
className="inline-flex items-center px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50"
>
Return to Dashboard
</a>
</div>
</div>
</div>
</div>
);
}

const fetchAppDetail = async () => {
setLoading(true);
setLoadingStep('Fetching analytics data...');
Expand All @@ -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[] => {
Expand Down Expand Up @@ -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);
Expand Down
Loading