Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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=
73 changes: 58 additions & 15 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -138,9 +138,52 @@ const getAttr = (key: string): string | undefined => {
**Security**:
- Private key (`SAML_SP_PRIVATE_KEY`) signs requests and decrypts assertions
- Public cert (`SAML_SP_CERT`) verified by Stanford IdP
- JWT tokens stored in HTTP-only cookies (not accessible to JavaScript)
- Session data encrypted in HTTP-only cookies using iron-session (not accessible to JavaScript)
- Clock skew: 5 minutes (`acceptedClockSkewMs: 300000`)

### Authorization System

**Two-Tier Access Control**:
1. **Global Access**: Users with specific `eduPersonEntitlement` values access everything
2. **Per-Application Access**: SUNet ID mappings grant access to specific applications

**Environment Variables**:
- `CHURRO_GLOBAL_ENTITLEMENTS` - Comma-separated list (e.g., `uit:sws,stanford:faculty`)
- `CHURRO_APP_ACCESS` - UUID:uid mappings (e.g., `uuid1:jdoe,jsmith;uuid2:jdoe`)

**Key Components** (`lib/auth-utils.ts`):
- `hasGlobalAccess(user)` - Check if user has global entitlement
- `hasApplicationAccess(user, uuid)` - Check specific app access
- `hasDashboardAccess(user)` - Check if can access dashboard (global access only)
- `parseAppAccessMappings()` - Parse environment variable mappings

**Middleware Protection** (`middleware.ts`):
- `/` - Dashboard requires global access only
- `/applications/[uuid]` - Requires global access OR specific application access
- Returns 403 with clear error messages for unauthorized access

**API Protection** (`lib/api-auth.ts`):
```typescript
export async function GET(request: NextRequest) {
return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => {
// Protected API logic with user context
return NextResponse.json({ data: 'protected' });
})(request);
}
```

**Client-Side Handling**:
- Authorization errors (403) display user-friendly error pages
- Client components check API response status and handle authorization failures
- Provides contact information and "Return to Dashboard" links

**Authorization Flow**:
1. User authenticates via Stanford SAML
2. Middleware checks global entitlements first
3. If no global access, checks per-application mappings
4. Routes/APIs enforce access before rendering/processing
5. Clear error messages for authorization failures

### Component Patterns

**Client Components** - Use `'use client'` directive when:
Expand Down Expand Up @@ -191,7 +234,7 @@ cp .env.example .env.local # Create env file
# Edit .env.local:
# APP_URL=https://localhost:3000
# SAML_ENTITY_ID=https://churro-test.stanford.edu (if needed)
# SESSION_SECRET=<generate with: openssl rand -base64 32>
# SESSION_SECRET=<generate with: openssl rand -base64 32> (REQUIRED for iron-session)

# Start development server
npm run dev:https # HTTPS server (required for SAML)
Expand Down Expand Up @@ -220,11 +263,11 @@ npm run dev # HTTP server (basic development, no SAML)

### Checking Auth Status Client-Side
```typescript
// Check if user is authenticated
// Check if user is authenticated (reads encrypted session cookie)
const response = await fetch('/api/auth/status')
const { authenticated, user } = await response.json()

// Logout
// Logout (clears encrypted session cookie)
window.location.href = '/api/auth/logout'
```

Expand Down Expand Up @@ -264,8 +307,8 @@ utilities/ # Helper utilities (datasource color mappings)
4. **Array vs single value** - SAML attributes may be arrays, use `getAttr()` helper
5. **Cache staleness** - 6-hour cache may hide API issues, check timestamps
6. **Decanter overrides** - Don't use arbitrary Tailwind values, use Decanter tokens
7. **User data in URLs** - Never pass sensitive user data in query params; use HTTP-only cookies
8. **Session secret missing** - Ensure `SESSION_SECRET` is set (required for session encryption)
7. **User data in URLs** - Never pass sensitive user data in query params; use encrypted iron-session cookies
8. **Session secret missing** - Ensure `SESSION_SECRET` is set (required for iron-session encryption)

## Key Documentation

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
22 changes: 20 additions & 2 deletions app/api/acquia/applications/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import AcquiaApiServiceFixed from '@/lib/acquia-api';
import { withApiAuthorization } from '@/lib/api-auth';
import { SamlUser } from '@/lib/session-auth';
import { hasGlobalAccess, parseAppAccessMappings } from '@/lib/auth-utils';

export async function GET(request: NextRequest) {
return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => {
// console.log('🚀 Applications API Route called');

// Update the API service initialization with better error handling
Expand Down Expand Up @@ -35,9 +39,22 @@ export async function GET(request: NextRequest) {

const applications = await apiService.getApplications();

// console.log('✅ Successfully fetched applications data, count:', applications.length);
// Apply user permission filtering server-side for security
const { user } = context;
let filteredApplications = applications;

return NextResponse.json(applications);
// If user doesn't have global access, filter to only authorized applications
if (!hasGlobalAccess(user)) {
const appMappings = parseAppAccessMappings();
filteredApplications = applications.filter(app => {
const allowedUsers = appMappings.get(app.uuid);
return allowedUsers && user.sunetId && allowedUsers.has(user.sunetId);
});
}

// console.log('✅ Successfully fetched applications data, count:', filteredApplications.length);

return NextResponse.json(filteredApplications);
} catch (error) {
console.error('❌ API Route Error:', error);

Expand All @@ -55,4 +72,5 @@ export async function GET(request: NextRequest) {
{ status: 500 }
);
}
})(request);
}
44 changes: 24 additions & 20 deletions app/api/acquia/views/route.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import AcquiaApiServiceFixed from '@/lib/acquia-api';
import { withApiAuthorization } from '@/lib/api-auth';
import { SamlUser } from '@/lib/session-auth';

export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const subscriptionUuid = searchParams.get('subscriptionUuid');
const from = searchParams.get('from');
const to = searchParams.get('to');
const resolution = searchParams.get('resolution'); // Get granularity for daily data
/**
console.log('🚀 Views by Application API Route called with params:', {
subscriptionUuid,
from,
to,
resolution
});
*/
if (!subscriptionUuid) {
console.error('❌ Missing required parameter: subscriptionUuid');
return NextResponse.json(
{ error: 'subscriptionUuid is required' },
{ status: 400 }
);
}
return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => {
const searchParams = request.nextUrl.searchParams;
const subscriptionUuid = searchParams.get('subscriptionUuid');
const from = searchParams.get('from');
const to = searchParams.get('to');
const resolution = searchParams.get('resolution'); // Get granularity for daily data
/**
console.log('🚀 Views by Application API Route called with params:', {
subscriptionUuid,
from,
to,
resolution
});
*/
if (!subscriptionUuid) {
console.error('❌ Missing required parameter: subscriptionUuid');
return NextResponse.json(
{ error: 'subscriptionUuid is required' },
{ status: 400 }
);
}

if (!process.env.ACQUIA_API_KEY || !process.env.ACQUIA_API_SECRET) {
console.error('❌ Missing required environment variables!');
Expand Down Expand Up @@ -82,4 +85,5 @@ export async function GET(request: NextRequest) {
{ status: 500 }
);
}
})(request);
}
4 changes: 4 additions & 0 deletions app/api/acquia/visits/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import AcquiaApiServiceFixed from '@/lib/acquia-api';
import { withApiAuthorization } from '@/lib/api-auth';
import { SamlUser } from '@/lib/session-auth';

export async function GET(request: NextRequest) {
return withApiAuthorization(async (request: NextRequest, context: { user: SamlUser }) => {
const searchParams = request.nextUrl.searchParams;
const subscriptionUuid = searchParams.get('subscriptionUuid');
const from = searchParams.get('from');
Expand Down Expand Up @@ -85,4 +88,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))
}
}

// Default redirect to home page
return NextResponse.redirect(new URL('/', baseUrl))
}
23 changes: 17 additions & 6 deletions app/api/saml/acs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down Expand Up @@ -77,14 +78,23 @@ async function processSamlResponse(request: NextRequest, samlResponse: string) {
// - ~50-100ms API call overhead per auth check is acceptable for this use case
// - Simpler implementation outweighs marginal performance gains
// - Iron-session provides encryption for enhanced security without added complexity
console.log('🔐 Creating session for user...')
await createSession(user)
console.log('✅ Session created successfully')

// Redirect to the application (or a relay state if available)
// Redirect to intermediate auth success page to handle client-side redirect
// This avoids timing issues with session cookies on server-side redirects to protected routes
const baseUrl = getBaseUrl(request)
const redirectUrl = new URL('/auth/test', baseUrl)
redirectUrl.searchParams.set('saml_success', 'true')
const returnTo = relayState || '/'
const authSuccessUrl = new URL('/auth/success', baseUrl)

return Response.redirect(redirectUrl.toString(), 302)
// Pass the return URL as a query parameter for client-side redirect
if (returnTo !== '/') {
authSuccessUrl.searchParams.set('returnTo', returnTo)
}

console.log('🔄 Redirecting to auth success page with returnTo:', returnTo)
return Response.redirect(authSuccessUrl.toString(), 302)
}

/**
Expand All @@ -97,13 +107,14 @@ export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const samlResponse = formData.get('SAMLResponse') as string
const relayState = formData.get('RelayState') as string

if (!samlResponse) {
throw new Error('No SAML response received in form data')
}

console.log('📨 POST: Processing SAML response from form data')
return await processSamlResponse(request, samlResponse)
return await processSamlResponse(request, samlResponse, relayState)

} catch (error) {
console.error('❌ SAML POST callback error:', error)
Expand Down
12 changes: 10 additions & 2 deletions app/api/saml/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ import { saml } from '@/lib/saml-config'

export async function GET(request: NextRequest) {
try {
const loginUrl = await saml.getAuthorizeUrlAsync('', '', {})
// Get the return URL from query parameters (set by middleware)
const returnTo = request.nextUrl.searchParams.get('returnTo') || '/'
console.log('🚀 SAML Login initiated with returnTo:', returnTo)

// Use RelayState to preserve the return URL through the SAML flow
console.log('🔗 Generating SAML login URL with RelayState...')
const loginUrl = await saml.getAuthorizeUrlAsync(returnTo, '', {})
console.log('✅ SAML login URL generated:', loginUrl)

return NextResponse.redirect(loginUrl)
} catch (error) {
console.error('Error initiating SAML login:', error)
console.error('Error initiating SAML login:', error)
return NextResponse.json({ error: 'Failed to initiate login' }, { status: 500 })
}
}
Loading