Skip to content
Merged
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
7 changes: 6 additions & 1 deletion .github/workflows/deploy-onboarding-status-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ jobs:
-e GCP_PROJECT_ID=test \
-e FIRESTORE_DATABASE_ID=test \
-e GITHUB_TOKEN=test \
-e NEXT_PUBLIC_GOOGLE_CLIENT_ID=test-client-id \
-e GOOGLE_CLIENT_SECRET=test-secret \
-e SESSION_SECRET=test-session-secret-32chars-min \
-e NEXT_PUBLIC_APP_URL=http://localhost:8080 \
-e ALLOWED_DOMAINS=vectorinstitute.ai \
onboarding-status-web:${{ github.sha }}

# Wait for container to start and verify it stays running
Expand Down Expand Up @@ -232,7 +237,7 @@ jobs:
--min-instances=0 \
--concurrency=80 \
--port=8080 \
--set-env-vars="GCP_PROJECT_ID=${{ env.PROJECT_ID }},FIRESTORE_DATABASE_ID=${{ env.FIRESTORE_DATABASE_ID }},GITHUB_TOKEN=${{ secrets.GH_ORG_TOKEN }},DEPLOYMENT_SHA=${{ github.sha }}" \
--set-env-vars="GCP_PROJECT_ID=${{ env.PROJECT_ID }},FIRESTORE_DATABASE_ID=${{ env.FIRESTORE_DATABASE_ID }},GITHUB_TOKEN=${{ secrets.GH_ORG_TOKEN }},DEPLOYMENT_SHA=${{ github.sha }},NEXT_PUBLIC_GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }},GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }},SESSION_SECRET=${{ secrets.SESSION_SECRET }},NEXT_PUBLIC_APP_URL=${{ secrets.APP_URL }},REDIRECT_URI=${{ secrets.REDIRECT_URI }},ALLOWED_DOMAINS=vectorinstitute.ai" \
--update-labels="deployed-by=github-actions,commit=${{ github.sha }}" \
--update-annotations="git-commit=${{ github.sha }},git-ref=${{ github.ref }},deployed-at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--quiet
Expand Down
99 changes: 99 additions & 0 deletions docs/onboarding-status-web-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Onboarding Status Web Authentication

## Overview

The Onboarding Status Web dashboard uses Google OAuth 2.0 with server-side sessions for authentication. Only @vectorinstitute.ai email addresses can access the dashboard.

## Architecture

- **Library**: `@vector-institute/aieng-auth-core`
- **Session Management**: `iron-session` with encrypted HTTP-only cookies
- **Security**: PKCE flow, domain restriction, encrypted sessions
- **Path**: All routes under `/onboarding` base path

## Authentication Flow

1. User visits `/onboarding` → redirected to `/onboarding/login` if not authenticated
2. Click "Sign in with Google" → `/onboarding/api/auth/login`
3. Google OAuth flow with PKCE
4. Callback to `/onboarding/api/auth/callback`
5. Session created, user redirected to dashboard

## Files

### Configuration
- `lib/auth-config.ts` - OAuth config
- `lib/session.ts` - Session management

### API Routes
- `app/api/auth/login/route.ts` - Initiate OAuth
- `app/api/auth/callback/route.ts` - Handle callback
- `app/api/auth/logout/route.ts` - Destroy session
- `app/api/auth/session/route.ts` - Get session info

### Pages
- `app/page.tsx` - Protected dashboard
- `app/login/page.tsx` - Login page
- `app/dashboard-content.tsx` - Dashboard UI

## Environment Variables

```bash
# OAuth
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret
SESSION_SECRET=generate-with-openssl-rand-base64-32

# URLs
NEXT_PUBLIC_APP_URL=http://localhost:3000
REDIRECT_URI=http://localhost:3000/onboarding/api/auth/callback

# Domain restriction
ALLOWED_DOMAINS=vectorinstitute.ai
```

## Local Development

1. Copy OAuth credentials from `aieng-template-auth/apps/demo-nextjs/.env`
2. Update redirect URI in `.env`: `http://localhost:3000/onboarding/api/auth/callback`
3. Run `npm run dev`
4. Visit `http://localhost:3000/onboarding`
5. Sign in with @vectorinstitute.ai account

## Production Deployment

### Required GitHub Secrets
- `GOOGLE_CLIENT_ID` - Shared Vector OAuth client ID
- `GOOGLE_CLIENT_SECRET` - OAuth client secret
- `SESSION_SECRET` - Generated with `openssl rand -base64 32`
- `APP_URL` - Production URL (e.g., `https://your-service.run.app`)
- `REDIRECT_URI` - Production callback URL (e.g., `https://your-service.run.app/onboarding/api/auth/callback`)

### Setup Steps
1. Get shared OAuth client ID from admin
2. Ask admin to add production redirect URI to Google OAuth client
3. Set GitHub secrets in repository settings
4. Deploy via GitHub Actions workflow

## Troubleshooting

**"Invalid redirect_uri"**
- Verify redirect URI registered in Google Cloud Console
- Check `REDIRECT_URI` matches registered value

**"Unauthorized domain"**
- User must have @vectorinstitute.ai email
- Check `ALLOWED_DOMAINS` environment variable

**Session issues**
- Verify `SESSION_SECRET` is at least 32 characters
- Clear browser cookies

## Dependencies

```json
{
"@vector-institute/aieng-auth-core": "^0.1.x",
"iron-session": "^8.0.1"
}
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ nav:
- Overview: developer_guide.md
- Infrastructure:
- Onboarding Status Web - Load Balancer Setup: onboarding-status-web-load-balancer-setup.md
- Onboarding Status Web - Authentication: onboarding-status-web-auth.md
plugins:
- search
- mkdocstrings:
Expand Down
20 changes: 20 additions & 0 deletions services/onboarding-status-web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Google Cloud Configuration
GCP_PROJECT_ID=coderd
FIRESTORE_DATABASE_ID=onboarding

# GitHub Configuration
GITHUB_TOKEN=your-github-token-here

# Google OAuth Configuration
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret-here

# Session Configuration (generate with: openssl rand -base64 32)
SESSION_SECRET=generate-a-random-32-character-string-here

# App URLs
NEXT_PUBLIC_APP_URL=http://localhost:3000
REDIRECT_URI=http://localhost:3000/onboarding/api/auth/callback

# Optional: Restrict access to specific email domains (comma-separated)
ALLOWED_DOMAINS=vectorinstitute.ai
55 changes: 46 additions & 9 deletions services/onboarding-status-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ A modern, real-time web dashboard built with Next.js to display participant onbo

## Features

- **Authentication**: Secure Google OAuth SSO with domain restriction (@vectorinstitute.ai)
- **Real-time Status Tracking**: Displays live participant onboarding status fetched from Firestore
- **Clean, Polished UI**: Modern, responsive design with dark mode support
- **Summary Statistics**: Shows total participants, onboarded count, completion percentage
Expand Down Expand Up @@ -53,18 +54,24 @@ services/onboarding-status-web/
npm install
```

2. Set environment variables:
2. Set up environment variables:
```bash
export GCP_PROJECT_ID=coderd
export FIRESTORE_DATABASE_ID=onboarding
cp .env.example .env
```

Then edit `.env` and fill in the required values:
- Get Google OAuth credentials from [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
- Generate a session secret: `openssl rand -base64 32`
- Set your GitHub token with appropriate permissions

3. Run the development server:
```bash
npm run dev
```

4. Open [http://localhost:3000](http://localhost:3000) in your browser
4. Open [http://localhost:3000/onboarding](http://localhost:3000/onboarding) in your browser

5. Sign in with a Vector Institute Google account (@vectorinstitute.ai)

## Deployment

Expand Down Expand Up @@ -120,15 +127,34 @@ cd /path/to/aieng-platform

## Environment Variables

### Required
- `GCP_PROJECT_ID`: Google Cloud Project ID (default: `coderd`)
- `FIRESTORE_DATABASE_ID`: Firestore database ID (default: `onboarding`)
- `GITHUB_TOKEN`: GitHub personal access token for API access
- `NEXT_PUBLIC_GOOGLE_CLIENT_ID`: Google OAuth client ID
- `GOOGLE_CLIENT_SECRET`: Google OAuth client secret
- `SESSION_SECRET`: Secret for encrypting session cookies (generate with: `openssl rand -base64 32`)

### Optional
- `NEXT_PUBLIC_APP_URL`: Full application URL (default: `http://localhost:3000`)
- `REDIRECT_URI`: OAuth callback URL (default: `${NEXT_PUBLIC_APP_URL}/onboarding/api/auth/callback`)
- `ALLOWED_DOMAINS`: Comma-separated list of allowed email domains (default: `vectorinstitute.ai`)
- `PORT`: Port to run the server on (default: `8080`)

## API Endpoints

### Authentication Endpoints

- `GET /api/auth/login` - Initiates Google OAuth flow
- `GET /api/auth/callback` - Handles OAuth callback and creates session
- `POST /api/auth/logout` - Destroys user session
- `GET /api/auth/session` - Returns current session information

### Data Endpoints

### GET /api/participants

Returns participant onboarding status and summary statistics.
Returns participant onboarding status and summary statistics. Requires authentication.

**Response:**
```json
Expand Down Expand Up @@ -174,10 +200,12 @@ Returns participant onboarding status and summary statistics.

## Security

- Uses Google Cloud service account authentication for Firestore access
- Runs as non-root user in Docker container
- Follows Cloud Run security best practices
- CORS configured for API routes
- **OAuth 2.0 Authentication**: Secure Google OAuth with PKCE flow
- **Session Management**: HTTP-only cookies with encrypted sessions using iron-session
- **Domain Restriction**: Only @vectorinstitute.ai email addresses can access
- **Firestore Access**: Service account authentication for database access
- **Container Security**: Runs as non-root user in Docker
- **CORS Configuration**: Properly configured API routes

## Performance

Expand Down Expand Up @@ -206,6 +234,15 @@ If the Docker build fails:
2. Check that the `public` directory exists
3. Verify Node.js version compatibility

### Authentication Issues

If you can't sign in:
1. Verify Google OAuth credentials are correct
2. Ensure redirect URI is registered in Google Cloud Console
3. Check that your email domain (@vectorinstitute.ai) is in ALLOWED_DOMAINS
4. Verify SESSION_SECRET is at least 32 characters
5. Check browser console for errors

### Firestore Connection Issues

If the dashboard can't fetch data:
Expand Down
96 changes: 96 additions & 0 deletions services/onboarding-status-web/app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { GoogleOAuthClient } from '@vector-institute/aieng-auth-core';
import { authConfig } from '@/lib/auth-config';
import { createSession } from '@/lib/session';
import { cookies } from 'next/headers';

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');

if (error) {
return NextResponse.redirect(new URL(`/onboarding?error=${encodeURIComponent(error)}`, request.url));
}

if (!code || !state) {
return NextResponse.redirect(new URL('/onboarding?error=invalid_callback', request.url));
}

// Verify state
const cookieStore = await cookies();
const storedState = cookieStore.get('oauth_state')?.value;
if (state !== storedState) {
return NextResponse.redirect(new URL('/onboarding?error=invalid_state', request.url));
}

// Get PKCE verifier
const verifier = cookieStore.get('pkce_verifier')?.value;
if (!verifier) {
return NextResponse.redirect(new URL('/onboarding?error=missing_verifier', request.url));
}

// Exchange code for tokens
const client = new GoogleOAuthClient(authConfig);

const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: authConfig.redirectUri,
client_id: authConfig.clientId,
client_secret: authConfig.clientSecret,
code_verifier: verifier,
});

const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Token exchange failed:', errorData);
return NextResponse.redirect(new URL('/onboarding?error=token_exchange_failed', request.url));
}

const data = await response.json();
const tokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
tokenType: data.token_type || 'Bearer',
expiresIn: data.expires_in,
scope: data.scope,
};

// Get user info
const user = await client.getUserInfo(tokens.accessToken);

// Validate domain if configured
if (authConfig.allowedDomains && authConfig.allowedDomains.length > 0) {
const domain = user.email?.split('@')[1];
if (!domain || !authConfig.allowedDomains.includes(domain)) {
return NextResponse.redirect(
new URL(`/onboarding?error=unauthorized_domain&domain=${domain}`, request.url)
);
}
}

// Create session
await createSession(tokens, user);

// Clean up temporary cookies
const redirectResponse = NextResponse.redirect(new URL('/onboarding', request.url));
redirectResponse.cookies.delete('pkce_verifier');
redirectResponse.cookies.delete('oauth_state');

return redirectResponse;
} catch (error) {
console.error('Callback error:', error);
return NextResponse.redirect(new URL('/onboarding?error=authentication_failed', request.url));
}
}
Loading