diff --git a/.env.test b/.env.test index 8623e685de..b293b23429 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,5 @@ SUPABASE_URL=http://localhost:54321 SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH SUPABASE_SERVICE_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz +API_SECRET=testsecret diff --git a/.gitignore b/.gitignore index 759a767b3c..a5d6b3e616 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,13 @@ playwright/.auth CHANGELOG.md .history +# Documentation and PR workspace files (not part of codebase) +PR_DESCRIPTION.md +SSO_IMPLEMENTATION_SUMMARY.md +SSO_TEST_STATUS.md +SSO_TESTING_GUIDE.md +PR_CHECKLIST.md + .env.dev cloudflare_workers_deno/cloudflare/* cloudflare_workers_deno/cloudflare_tests/* @@ -63,6 +70,7 @@ internal/cloudflare/.env.prod scripts/local_cf_backend/spawn temp_cli_test internal/supabase/.env.prod +supabase/.env cloudflare_workers/files/.wrangler/* cloudflare_workers/plugin/.wrangler/* tmp diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index fd99974844..9786628847 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -9,6 +9,11 @@ import { app as events } from '../../supabase/functions/_backend/private/events. import { app as log_as } from '../../supabase/functions/_backend/private/log_as.ts' import { app as plans } from '../../supabase/functions/_backend/private/plans.ts' import { app as publicStats } from '../../supabase/functions/_backend/private/public_stats.ts' +import { app as sso_configure } from '../../supabase/functions/_backend/private/sso_configure.ts' +import { app as sso_remove } from '../../supabase/functions/_backend/private/sso_remove.ts' +import { app as sso_status } from '../../supabase/functions/_backend/private/sso_status.ts' +import { app as sso_test } from '../../supabase/functions/_backend/private/sso_test.ts' +import { app as sso_update } from '../../supabase/functions/_backend/private/sso_update.ts' import { app as stats_priv } from '../../supabase/functions/_backend/private/stats.ts' import { app as storeTop } from '../../supabase/functions/_backend/private/store_top.ts' import { app as stripe_checkout } from '../../supabase/functions/_backend/private/stripe_checkout.ts' @@ -77,6 +82,11 @@ appPrivate.route('/stripe_portal', stripe_portal) appPrivate.route('/delete_failed_version', deleted_failed_version) appPrivate.route('/create_device', create_device) appPrivate.route('/events', events) +appPrivate.route('/sso/configure', sso_configure) +appPrivate.route('/sso/update', sso_update) +appPrivate.route('/sso/remove', sso_remove) +appPrivate.route('/sso/status', sso_status) +appPrivate.route('/sso/test', sso_test) // Triggers const functionNameTriggers = 'triggers' diff --git a/docs/MOCK_SSO_TESTING.md b/docs/MOCK_SSO_TESTING.md new file mode 100644 index 0000000000..950746996a --- /dev/null +++ b/docs/MOCK_SSO_TESTING.md @@ -0,0 +1,210 @@ +# Mock SSO Testing Guide + +This mock SSO endpoint simulates Okta's SAML authentication flow for local development. It replicates the exact behavior you'll see in production with real Okta SAML SSO. + +## How It Works + +### Production SAML Flow (with Okta) +1. User enters email → Frontend checks if SSO is configured +2. User clicks "Continue with SSO" → Redirects to Okta login page +3. User authenticates at Okta → Okta generates SAML assertion +4. Okta POSTs SAML response to Supabase ACS URL (`/auth/v1/sso/saml/acs`) +5. Supabase validates SAML, creates session, redirects back to app with tokens + +### Local Mock Flow (simulated) +1. User enters email → Frontend checks if SSO is configured ✅ (uses real database) +2. User clicks "Continue with SSO" → Redirects to **mock endpoint** instead of Okta +3. Mock endpoint validates SSO config ✅ (queries real database) +4. Mock creates/authenticates user ✅ (uses Supabase admin API) +5. Mock generates session tokens and redirects back to app ✅ (same as production) + +**The only difference:** Steps 2-4 are simulated locally instead of going to Okta. Everything else is identical to production. + +## Prerequisites + +1. **Supabase running locally:** + ```bash + supabase start + ``` + +2. **Database seeded with SSO configuration:** + ```bash + supabase db reset + ``` + +3. **SSO domain configured** (from your SSO setup in the UI): + - Domain: `congocmc.com` + - Provider ID: Generated when you created the config + - Enabled: `true` + - Verified: `true` + +## Testing Steps + +### 1. Start the Frontend +```bash +bun serve:local +``` + +### 2. Navigate to SSO Login +Go to: http://localhost:5173/sso-login + +### 3. Enter a Test Email +Use an email with a domain that has SSO configured: +``` +nathank@congocmc.com +``` + +### 4. Click "Continue" +The page will: +- Detect it's running locally +- Redirect to the mock endpoint: + ``` + http://localhost:54321/functions/v1/mock-sso-callback?email=nathank@congocmc.com&RelayState=/dashboard + ``` + +### 5. Mock Endpoint Processes +The mock endpoint will: +1. ✅ Validate SSO is configured for `congocmc.com` domain +2. ✅ Check if user `nathank@congocmc.com` exists (creates if not) +3. ✅ Generate access & refresh tokens via Supabase admin API +4. ✅ Show success page with 2-second countdown +5. ✅ Redirect to app with tokens in URL hash + +### 6. Auto-Join Triggers Execute +After redirect, the auth trigger automatically: +- ✅ Checks if user's email domain has `allow_all_subdomains` enabled +- ✅ Finds "Admin org" with `allow_all_subdomains: true` for `congocmc.com` +- ✅ Adds user to "Admin org" with role from `default_role` +- ✅ User is logged in and org membership is automatic + +### 7. Verify Success +- User is logged in ✅ +- Dashboard loads ✅ +- User has access to "Admin org" ✅ + +## Test Scenarios + +### Test 1: First-Time SSO User +``` +Email: newuser@congocmc.com +Expected: + - User created automatically + - Logged in successfully + - Added to "Admin org" +``` + +### Test 2: Existing SSO User +``` +Email: nathank@congocmc.com (if already created) +Expected: + - User authenticated + - Logged in successfully + - Existing org membership preserved +``` + +### Test 3: Non-SSO Domain +``` +Email: test@gmail.com +Expected: + - SSO detection fails + - Error: "SSO is not configured for this email domain" +``` + +### Test 4: Unverified Domain +Modify database to set `verified = false`: +```sql +UPDATE saml_domain_mappings SET verified = false WHERE domain = 'congocmc.com'; +``` +Expected: + - Mock returns error + - Error: "SSO is not configured for domain: congocmc.com" + +## Debugging + +### Check Mock Endpoint Logs +```bash +docker logs -f supabase_edge_runtime_capgo-app +``` + +### Check Database State +```bash +# Check SSO configuration +docker exec -it supabase_db_capgo-app psql -U postgres -d postgres -c " + SELECT d.domain, d.verified, c.enabled, c.provider_id + FROM saml_domain_mappings d + JOIN org_saml_connections c ON d.connection_id = c.id + WHERE d.domain = 'congocmc.com'; +" + +# Check user was created/authenticated +docker exec -it supabase_db_capgo-app psql -U postgres -d postgres -c " + SELECT id, email, created_at, raw_user_meta_data->>'sso_provider' as sso_provider + FROM auth.users + WHERE email LIKE '%@congocmc.com'; +" + +# Check org membership +docker exec -it supabase_db_capgo-app psql -U postgres -d postgres -c " + SELECT ou.user_id, ou.org_id, ou.user_right, o.name as org_name + FROM org_users ou + JOIN orgs o ON o.id = ou.org_id + WHERE ou.user_id IN ( + SELECT id FROM auth.users WHERE email LIKE '%@congocmc.com' + ); +" +``` + +### Common Issues + +**Issue:** "SSO is not configured for domain" +- **Fix:** Verify domain is in `saml_domain_mappings` with `verified = true` +- **Check:** Connection is `enabled = true` in `org_saml_connections` + +**Issue:** User created but not added to org +- **Fix:** Check `allow_all_subdomains = true` in org settings +- **Verify:** Trigger `auto_join_user_to_orgs_on_create` exists and is enabled + +**Issue:** Mock endpoint returns 500 +- **Fix:** Check Supabase logs for detailed error +- **Verify:** Service role key is configured correctly + +## Production vs Mock Comparison + +| Aspect | Production (Okta) | Mock (Local) | +|--------|-------------------|--------------| +| SSO detection | ✅ Real DB | ✅ Real DB | +| User authentication | Okta SAML page | Simulated success | +| User creation | Supabase auto | ✅ Same (admin API) | +| Token generation | Supabase | ✅ Same (magic link) | +| Auto-join trigger | ✅ Executed | ✅ Executed | +| Session management | ✅ Real | ✅ Real | +| Redirect with tokens | ✅ Real | ✅ Real | + +**What's mocked:** Only the Okta authentication page (user typing password) +**What's real:** Everything else - database, triggers, Supabase auth, session management + +## Transitioning to Production + +When deploying to production with real Okta: + +1. **No frontend code changes needed** - The check for `localhost` will use real SSO +2. **Update Okta configuration** with your production URLs: + - ACS URL: `https://yourapp.com/auth/v1/sso/saml/acs` + - Entity ID: Your Supabase project URL +3. **Enable SAML in Supabase Dashboard** (available on Pro plan+) +4. **Test with real Okta credentials** + +The mock endpoint can remain in your codebase - it won't be used in production because the hostname check will fail. + +## Mock Implementation Details + +The mock endpoint (`/functions/v1/mock-sso-callback`) accurately simulates: + +1. **SAML Response Validation** - Checks domain mapping and provider status +2. **User Attributes** - Extracts email, firstName, lastName (from email) +3. **Session Creation** - Uses same token generation as production +4. **RelayState Handling** - Preserves redirect URL through the flow +5. **Error Responses** - Same error messages as production +6. **Success Page** - Visual feedback matching production UX + +This ensures your local testing experience matches production behavior exactly. diff --git a/docs/sso-production.md b/docs/sso-production.md new file mode 100644 index 0000000000..60492b007b --- /dev/null +++ b/docs/sso-production.md @@ -0,0 +1,574 @@ +# SSO Production Deployment Guide + +## Prerequisites Checklist + +Before deploying SSO to production, ensure all requirements are met: + +### ✅ Supabase Pro Plan + +- [ ] **Plan Upgrade**: Upgrade from Free to Pro plan + - Cost: $25/month base + $0.015 per SSO MAU + - Upgrade at: https://supabase.com/dashboard/project/_/settings/billing +- [ ] **Billing Configured**: Valid payment method on file +- [ ] **Pro Features Enabled**: Verify Pro badge in dashboard + +### ✅ Environment Variables + +- [ ] **SUPABASE_SERVICE_ROLE_KEY**: Set in production environment + - Location: Supabase Dashboard → Settings → API → service_role key + - Must have full database access for SSO operations + - Store securely (environment variables, secrets manager) + +```bash +# Cloudflare Workers (.env or wrangler.toml) +SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + +# Supabase Edge Functions (internal/supabase/.env.production) +SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +- [ ] **SUPABASE_URL**: Production Supabase project URL + - Format: `https://YOUR_PROJECT_ID.supabase.co` +- [ ] **SUPABASE_ANON_KEY**: Public anon key for frontend + +### ✅ Database Migrations + +- [ ] **Migrations Applied**: All SSO migrations deployed to production + ```bash + # Apply migrations to production + supabase db push --linked + ``` + +- [ ] **Verify Tables Created**: + ```sql + -- Verify SSO tables exist + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('org_saml_connections', 'saml_domain_mappings', 'sso_audit_logs'); + ``` + +- [ ] **RLS Policies Active**: + ```sql + -- Check RLS is enabled + SELECT tablename, rowsecurity FROM pg_tables + WHERE schemaname = 'public' + AND tablename IN ('org_saml_connections', 'saml_domain_mappings', 'sso_audit_logs'); + ``` + +### ✅ Backend Deployment + +- [ ] **SSO Endpoints Deployed**: + - Cloudflare Workers: `bun deploy:cloudflare:api:prod` + - Supabase Functions: `bun deploy:supabase:prod` + +- [ ] **Verify Endpoints Accessible**: + ```bash + # Test SSO status endpoint + curl -H "apisecret: YOUR_API_SECRET" \ + "https://api.capgo.app/private/sso/status?orgId=TEST_ORG_ID" + ``` + +- [ ] **Environment-Specific Config**: Verify production config in `internal/cloudflare/.env.production` + +### ✅ Frontend Deployment + +- [ ] **SSO UI Deployed**: `src/pages/settings/organization/sso.vue` in production +- [ ] **SSO Detection Active**: `useSSODetection` composable working +- [ ] **Login Flow Updated**: SSO banner appears for configured domains + +### ✅ Supabase CLI Access + +- [ ] **CLI Installed**: `supabase --version` returns v1.0.0+ +- [ ] **Project Linked**: `supabase link --project-ref YOUR_PROJECT_ID` +- [ ] **Auth Configured**: Service role key available for CLI + +```bash +# Verify CLI can execute SSO commands +supabase sso list --project-ref YOUR_PROJECT_ID +``` + +--- + +## Upgrade Process: Free → Pro Plan + +### Step 1: Upgrade Supabase Plan + +1. Go to https://supabase.com/dashboard/project/_/settings/billing +2. Click **Upgrade to Pro** +3. Add payment method +4. Confirm upgrade + +**Expected Changes:** +- Pro badge appears in dashboard +- SSO menu item appears in Auth settings +- Service role key gains SSO permissions + +### Step 2: Verify Pro Features + +```bash +# Test SSO CLI access +supabase sso list --project-ref YOUR_PROJECT_ID + +# Expected output: +# No SSO providers configured (this is normal initially) +``` + +If you see "SSO is only available on the Pro plan" error, wait 5 minutes for upgrade to propagate. + +### Step 3: Configure Environment + +Update production environment variables: + +```bash +# Cloudflare Workers +cd internal/cloudflare +cp .env.production.example .env.production +# Edit .env.production with SUPABASE_SERVICE_ROLE_KEY + +# Deploy with updated env +cd ../../ +bun deploy:cloudflare:api:prod +``` + +### Step 4: Deploy Database Migrations + +```bash +# Link to production project +supabase link --project-ref YOUR_PROJECT_ID + +# Push SSO migrations +supabase db push --linked + +# Verify migrations applied +supabase migration list --linked +``` + +--- + +## Security Configuration + +### Service Role Key Management + +**⚠️ CRITICAL: Service role key bypasses RLS policies** + +1. **Storage**: + - Use environment variables or secrets manager + - Never commit to version control + - Rotate every 90 days + +2. **Access Control**: + - Only accessible to backend services + - Never expose to frontend + - Monitor usage in audit logs + +3. **Rotation Process**: + ```bash + # Generate new service role key in Supabase dashboard + # Update environment variables + # Redeploy all services + # Verify old key no longer works + # Update documentation with rotation date + ``` + +### IdP Certificate Management + +1. **Certificate Expiration Monitoring**: + - Set up alerts 30 days before expiration + - Test renewal process in staging + - Update metadata before expiration + +2. **Certificate Validation**: + ```bash + # Verify certificate not expired + openssl x509 -in certificate.pem -noout -enddate + ``` + +### HTTPS Enforcement + +- [ ] All metadata URLs use HTTPS +- [ ] IdP endpoints use valid SSL certificates +- [ ] No mixed content warnings in browser + +--- + +## Monitoring & Alerting + +### Health Checks + +Create monitoring for these metrics: + +```sql +-- Daily SSO authentication count +SELECT + DATE(created_at) as date, + COUNT(*) as sso_logins +FROM sso_audit_logs +WHERE event_type = 'sso_config_viewed' +AND created_at > NOW() - INTERVAL '30 days' +GROUP BY DATE(created_at) +ORDER BY date DESC; + +-- Failed SSO attempts (check application logs) +-- Active SSO organizations +SELECT COUNT(DISTINCT org_id) as active_sso_orgs +FROM org_saml_connections +WHERE enabled = true; + +-- SSO MAU estimate (for billing) +SELECT COUNT(DISTINCT user_id) as sso_mau +FROM sso_audit_logs +WHERE created_at > NOW() - INTERVAL '30 days'; +``` + +### Alert Thresholds + +Set up alerts for: + +| Metric | Threshold | Action | +|--------|-----------|--------| +| Failed SSO logins | > 10/hour | Investigate IdP issues | +| Certificate expiration | < 30 days | Renew certificate | +| SSO MAU | > 80% of budget | Review costs, optimize | +| Service role key usage | > 1000/hour | Check for abuse | + +### Log Aggregation + +Forward SSO audit logs to monitoring service: + +```javascript +// Cloudflare Workers logging +export default { + async fetch(request, env) { + // ... SSO logic ... + + // Send to logging service + await fetch('https://logs.yourcompany.com/ingest', { + method: 'POST', + body: JSON.stringify({ + event: 'sso_audit', + timestamp: new Date().toISOString(), + org_id: orgId, + event_type: eventType, + ip_address: request.headers.get('cf-connecting-ip'), + user_agent: request.headers.get('user-agent') + }) + }) + } +} +``` + +--- + +## Cost Management + +### Baseline Costs + +| Component | Cost | Frequency | +|-----------|------|-----------| +| Supabase Pro Plan | $25 | Monthly | +| SSO MAU (first 100,000) | $0 | Included | +| SSO MAU (additional) | $0.015 | Per user/month | + +### Cost Estimation + +**Formula**: `$25 + (SSO_MAU * $0.015)` + +| SSO Users | Monthly Cost | Annual Cost | +|-----------|--------------|-------------| +| 50 | $25.75 | $309 | +| 100 | $26.50 | $318 | +| 250 | $28.75 | $345 | +| 500 | $32.50 | $390 | +| 1,000 | $40.00 | $480 | +| 5,000 | $100.00 | $1,200 | + +### Cost Optimization Strategies + +1. **Hybrid Authentication**: + - Keep password auth enabled + - Only critical users via SSO + - Monitor MAU usage weekly + +2. **Domain Segmentation**: + - Enable SSO for premium customers only + - Regular customers use password auth + - Tier-based SSO access + +3. **Session Management**: + - Increase session duration to reduce auth frequency + - Use refresh tokens efficiently + - Cache authentication state + +4. **Monitoring**: + ```sql + -- Track SSO vs password usage + SELECT + provider, + COUNT(*) as login_count + FROM auth.sessions + WHERE created_at > NOW() - INTERVAL '30 days' + GROUP BY provider; + ``` + +--- + +## Disaster Recovery + +### Backup Strategy + +1. **Database Backups**: + ```bash + # Backup SSO configuration + pg_dump $DATABASE_URL \ + --table=org_saml_connections \ + --table=saml_domain_mappings \ + --table=sso_audit_logs \ + > sso_backup_$(date +%Y%m%d).sql + ``` + +2. **Configuration Backup**: + ```bash + # Export SSO providers via CLI + supabase sso list --project-ref YOUR_PROJECT_ID --format json \ + > sso_providers_$(date +%Y%m%d).json + ``` + +3. **Backup Schedule**: + - Daily: Automated database backups (Supabase includes) + - Weekly: Export SSO configuration to S3/R2 + - Monthly: Full disaster recovery test + +### Recovery Procedures + +**Scenario 1: SSO Configuration Deleted** + +```bash +# Restore from SQL backup +psql $DATABASE_URL < sso_backup_20240101.sql + +# Verify restoration +psql $DATABASE_URL -c "SELECT COUNT(*) FROM org_saml_connections;" +``` + +**Scenario 2: IdP Metadata Changed** + +```bash +# Re-add SSO provider via CLI +supabase sso add \ + --project-ref YOUR_PROJECT_ID \ + --metadata-url https://idp.example.com/metadata \ + --domains example.com +``` + +**Scenario 3: Service Role Key Compromised** + +1. Generate new service role key in Supabase dashboard +2. Update all environment variables immediately +3. Redeploy all services (API, plugins, files workers) +4. Monitor audit logs for unauthorized access +5. Revoke old key +6. Force re-authentication for all users + +### Testing Recovery + +```bash +# Quarterly DR test procedure +1. Export production SSO config +2. Create staging environment +3. Import config to staging +4. Test SSO login flow +5. Verify audit logging +6. Document recovery time +``` + +--- + +## Rollback Plan + +If SSO causes issues in production: + +### Immediate Actions + +1. **Disable SSO for Organization**: + ```sql + UPDATE org_saml_connections + SET enabled = false + WHERE org_id = 'AFFECTED_ORG_ID'; + ``` + +2. **Notify Users**: + - Send email to organization admins + - Update status page + - Provide password reset links + +3. **Switch to Password Auth**: + - Users can use "Sign in with password" + - Password reset flow available + - MFA still works + +### Complete Rollback + +If SSO needs to be completely removed: + +```bash +# 1. Backup current state +pg_dump $DATABASE_URL > pre_rollback_backup.sql + +# 2. Disable all SSO configurations +psql $DATABASE_URL -c "UPDATE org_saml_connections SET enabled = false;" + +# 3. Optionally remove SSO data (CAUTION) +psql $DATABASE_URL < 90% positive feedback + +### Next Steps After Deployment + +1. Monitor first 24 hours closely +2. Collect feedback from early adopters +3. Iterate on UX based on feedback +4. Document edge cases encountered +5. Train support team on SSO troubleshooting +6. Plan rollout to remaining organizations + +For detailed setup instructions, see [SSO Setup Guide](./sso-setup.md). diff --git a/docs/sso-setup.md b/docs/sso-setup.md new file mode 100644 index 0000000000..6da55124b6 --- /dev/null +++ b/docs/sso-setup.md @@ -0,0 +1,534 @@ +# SSO Setup Guide + +## Overview + +Capgo supports SAML 2.0 Single Sign-On (SSO) for enterprise organizations. This guide covers configuring SSO with popular Identity Providers (IdPs) including Okta, Azure AD, and Google Workspace. + +## Prerequisites + +- **Supabase Pro Plan**: SSO requires a Supabase Pro subscription ($25/month + $0.015 per SSO MAU) +- **Super Admin Access**: Only users with `super_admin` role can configure SSO +- **Verified Email Domain**: Domain must be owned by your organization + +## Quick Start + +1. Navigate to **Settings** → **Organization** → **SSO** +2. Copy the **Capgo SAML metadata** (Entity ID and ACS URL) +3. Configure your IdP using the metadata +4. Enter your IdP's metadata URL or XML in Capgo +5. Add email domains for auto-enrollment +6. Test the connection +7. Enable SSO for your organization + +--- + +## Identity Provider Guides + +### Okta + +#### 1. Create SAML Application + +1. In Okta Admin Console, go to **Applications** → **Applications** +2. Click **Create App Integration** +3. Select **SAML 2.0** and click **Next** +4. Enter application name (e.g., "Capgo") + +#### 2. Configure SAML Settings + +**General Settings:** +- **Single sign-on URL**: Use ACS URL from Capgo SSO page + - Example: `https://YOUR_PROJECT_ID.supabase.co/auth/v1/sso/saml/acs` +- **Audience URI (SP Entity ID)**: Use Entity ID from Capgo SSO page + - Example: `https://YOUR_PROJECT_ID.supabase.co/auth/v1/sso/saml/metadata` +- **Name ID format**: `EmailAddress` +- **Application username**: `Email` + +**Attribute Statements:** +| Name | Name Format | Value | +|------|-------------|-------| +| `email` | Unspecified | `user.email` | +| `first_name` | Unspecified | `user.firstName` | +| `last_name` | Unspecified | `user.lastName` | + +#### 3. Get Metadata + +1. After saving, go to **Sign On** tab +2. Copy the **Metadata URL** (or download XML) +3. Paste into Capgo SSO configuration step 2 + +#### 4. Assign Users + +1. Go to **Assignments** tab +2. Click **Assign** → **Assign to People** or **Assign to Groups** +3. Add users who should access Capgo + +#### 5. Complete Setup + +1. In Capgo, add your email domains (e.g., `yourcompany.com`) +2. Click **Test Connection** +3. If successful, enable SSO + +--- + +### Azure AD (Microsoft Entra ID) + +#### 1. Create Enterprise Application + +1. In Azure Portal, go to **Microsoft Entra ID** → **Enterprise applications** +2. Click **New application** +3. Click **Create your own application** +4. Name it "Capgo" and select **Integrate any other application (Non-gallery)** + +#### 2. Configure SSO + +1. In the application, go to **Single sign-on** +2. Select **SAML** +3. Click **Edit** on **Basic SAML Configuration** + +**Configuration:** +- **Identifier (Entity ID)**: Use Entity ID from Capgo + - Example: `https://YOUR_PROJECT_ID.supabase.co/auth/v1/sso/saml/metadata` +- **Reply URL (Assertion Consumer Service URL)**: Use ACS URL from Capgo + - Example: `https://YOUR_PROJECT_ID.supabase.co/auth/v1/sso/saml/acs` +- **Sign on URL**: `https://capgo.app/login` + +#### 3. Configure Attributes & Claims + +Default claims should work, but verify: +| Claim Name | Value | +|------------|-------| +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `user.mail` | +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` | `user.givenname` | +| `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` | `user.surname` | + +#### 4. Get Metadata + +1. In **SAML Certificates** section, copy **App Federation Metadata URL** +2. Paste into Capgo SSO configuration step 2 + +#### 5. Assign Users + +1. Go to **Users and groups** +2. Click **Add user/group** +3. Select users who should access Capgo + +#### 6. Complete Setup + +1. In Capgo, add your email domains (e.g., `yourcompany.com`) +2. Click **Test Connection** +3. If successful, enable SSO + +--- + +### Google Workspace + +#### 1. Create Custom SAML App + +1. In Google Admin Console, go to **Apps** → **Web and mobile apps** +2. Click **Add app** → **Add custom SAML app** +3. Enter app name (e.g., "Capgo") + +#### 2. Download Google IdP Information + +1. Copy or download the **SSO URL** and **Certificate** +2. Download the **Metadata file** (XML) +3. Click **Continue** + +#### 3. Configure Service Provider Details + +**Configuration:** +- **ACS URL**: Use ACS URL from Capgo SSO page + - Example: `https://YOUR_PROJECT_ID.supabase.co/auth/v1/sso/saml/acs` +- **Entity ID**: Use Entity ID from Capgo SSO page + - Example: `https://YOUR_PROJECT_ID.supabase.co/auth/v1/sso/saml/metadata` +- **Name ID format**: `EMAIL` +- **Name ID**: `Basic Information > Primary email` + +#### 4. Configure Attribute Mapping + +| Google Directory attribute | App attribute | +|----------------------------|---------------| +| Primary email | `email` | +| First name | `first_name` | +| Last name | `last_name` | + +#### 5. Upload Metadata to Capgo + +1. Use the metadata XML file downloaded in step 2 +2. In Capgo SSO configuration, select **Metadata XML** tab +3. Paste the XML content + +#### 6. Turn on the App + +1. In Google Admin, go to **User access** +2. Select **ON for everyone** or specific organizational units +3. Click **Save** + +#### 7. Complete Setup + +1. In Capgo, add your Google Workspace domains +2. Click **Test Connection** +3. If successful, enable SSO + +--- + +## Domain Management + +### Adding Domains + +1. In SSO configuration step 3, enter email domains +2. Domains should be in format: `yourcompany.com` (no `@` prefix) +3. Multiple domains can be added for multi-domain organizations +4. Domains are automatically verified through SSO authentication + +### Domain Priority + +When multiple domains are configured: +- SSO providers take priority over email domain auto-join +- If a domain has SSO, users must use SSO to join +- Users authenticating via SSO are automatically enrolled with `read` permission + +### Removing Domains + +1. Click the **X** next to a domain to remove it +2. Removing a domain does NOT remove existing users +3. New users from that domain will no longer auto-enroll + +--- + +## Testing SSO + +### Before Enabling + +1. Click **Test Connection** in step 4 +2. This opens your IdP login in a new window +3. Authenticate with a test user +4. Verify successful authentication +5. Check that user attributes are correctly mapped + +### Common Test Issues + +**Issue**: "Invalid SSO provider" +- **Cause**: Metadata URL is incorrect or unreachable +- **Fix**: Verify metadata URL is publicly accessible + +**Issue**: "Audience validation failed" +- **Cause**: Entity ID mismatch between Capgo and IdP +- **Fix**: Ensure Entity ID matches exactly in both systems + +**Issue**: "ACS URL mismatch" +- **Cause**: Reply URL in IdP doesn't match ACS URL +- **Fix**: Update IdP configuration with correct ACS URL + +--- + +## Security Best Practices + +### Metadata Security + +- **Use HTTPS**: Always use HTTPS metadata URLs +- **Verify Certificates**: Ensure IdP certificates are valid +- **Rotate Regularly**: Update IdP certificates before expiration + +### SSRF Protection + +Capgo blocks metadata URLs pointing to: +- `localhost`, `127.0.0.1` +- Private IP ranges (`10.x`, `172.16-31.x`, `192.168.x`) +- AWS metadata endpoint (`169.254.169.254`) +- Internal infrastructure endpoints + +### XML Security + +Capgo rejects metadata XML containing: +- ` NOW() - INTERVAL '1 day';" +``` + +### Backup & Recovery + +1. **Backup SSO Configuration:** + ```sql + -- Export SSO connections + COPY ( + SELECT * FROM org_saml_connections + ) TO '/tmp/sso_connections_backup.csv' CSV HEADER; + + -- Export domain mappings + COPY ( + SELECT * FROM saml_domain_mappings + ) TO '/tmp/sso_domains_backup.csv' CSV HEADER; + ``` + +2. **Restore from Backup:** + ```sql + -- Restore SSO connections + COPY org_saml_connections FROM '/tmp/sso_connections_backup.csv' CSV HEADER; + + -- Restore domain mappings + COPY saml_domain_mappings FROM '/tmp/sso_domains_backup.csv' CSV HEADER; + ``` + +--- + +## Support + +### Documentation + +- [Supabase SSO Documentation](https://supabase.com/docs/guides/auth/enterprise-sso/auth-sso-saml) +- [SAML 2.0 Specification](https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) + +### Common Resources + +- **Capgo Dashboard**: https://capgo.app +- **Support Email**: support@capgo.app +- **Status Page**: https://status.capgo.app + +### Getting Help + +For SSO-specific issues: +1. Check audit logs for error details +2. Verify IdP configuration matches Capgo metadata +3. Test with a new user account +4. Contact support with: + - Organization ID + - IdP provider (Okta, Azure AD, etc.) + - Error messages from audit logs + - Screenshot of IdP configuration diff --git a/messages/en.json b/messages/en.json index c70196cca5..9debeb33dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -837,6 +837,8 @@ "login-to-your-accoun": "Login to your account", "login-to-your-account": "Login to your account", "logout": "logout", + "or": "or", + "sign-in-with-sso": "Sign in with SSO", "logs": "Logs", "main-bundle-number": "Main bundle number", "major": "Major", @@ -1349,6 +1351,88 @@ "your-settings": "Your settings", "your-usage": "Your usage:", "zip-bundle": "Zip app bundle", + "acs-url": "Assertion Consumer Service URL", + "acs-url-help": "This is the URL where your identity provider will send SAML assertions", + "add": "Add", + "add-domain": "Add Domain", + "add-email-domain": "Add Email Domain", + "auto-join": "Auto-Join", + "auto-join-status": "Auto-Join Status", + "auto-join-enabled": "Auto-join enabled", + "auto-join-enabled-description": "SSO users are automatically added to the organization", + "auto-join-disabled": "Auto-join disabled", + "auto-join-disabled-description": "SSO users must be manually invited", + "auto-join-title": "Automatic User Provisioning", + "auto-join-description": "Enable automatic user provisioning to allow users with verified email domains to join your organization automatically when signing in through SSO", + "auto-join-benefit": "When enabled, users authenticating via SSO with a verified email domain will automatically be added to your organization", + "auto-join-warning": "Auto-join requires SSO to be enabled and at least one verified domain", + "auto-join-requires-sso": "Auto-join requires SSO to be enabled", + "auto-join-requires-sso-enabled": "SSO must be enabled to use auto-join", + "auto-join-enabled-toast": "Auto-join enabled", + "auto-join-disabled-toast": "Auto-join disabled", + "back": "Back", + "copy-success": "Copied to clipboard", + "configuration-review": "Configuration Review", + "current-configuration": "Current Configuration", + "domain-placeholder": "example.com", + "domains": "Domains", + "domain-sso-help": "Add email domains that should be allowed to authenticate via SSO. Users with these email domains will be able to sign in using your identity provider", + "edit-configuration": "Edit Configuration", + "email-domain": "Email Domain", + "email-domains": "Email Domains", + "enable-sso": "Enable SSO", + "enable-sso-description": "Once enabled, users from verified domains will be able to sign in using your identity provider", + "enable-sso-to-use-auto-join": "Enable SSO to use auto-join", + "entity-id": "Entity ID", + "entity-id-help": "This is the unique identifier for your Capgo organization in the SAML exchange", + "error-loading-sso": "Error loading SSO configuration", + "error-toggling-sso": "Failed to update SSO setting", + "error-toggling-auto-join": "Failed to update auto-join setting", + "idp-config": "Identity Provider Configuration", + "idp-metadata-url-help": "The URL where Capgo can fetch your identity provider's SAML metadata", + "idp-metadata-xml": "Identity Provider Metadata XML", + "idp-metadata-xml-help": "Paste the SAML metadata XML from your identity provider", + "metadata-url": "Metadata URL", + "metadata-url-help": "Use this URL to download the SAML metadata for your Capgo organization", + "metadata-url-placeholder": "https://your-idp.com/saml/metadata", + "metadata-xml": "Metadata XML", + "next": "Next", + "provider": "Provider", + "provider-name": "Provider Name", + "provider-name-help": "A friendly name for your identity provider (e.g., 'Okta', 'Azure AD')", + "requirement-custom-domain": "Custom domain must be configured", + "requirement-idp-metadata": "Identity Provider metadata must be provided", + "requirements": "Requirements", + "save-and-continue": "Save and Continue", + "sso": "SSO", + "sso-active": "SSO Active", + "sso-active-description": "Single Sign-On is configured and enabled for your organization", + "sso-enabled": "SSO enabled", + "sso-disabled": "SSO disabled", + "sso-inactive": "SSO Inactive", + "sso-inactive-description": "SSO is currently not active. Complete the configuration wizard to enable it.", + "sso-configuration": "SSO Configuration", + "sso-domains": "SSO Domains", + "sso-saml-configuration": "SSO SAML Configuration", + "sso-status": "SSO Status", + "step-1-title": "Step 1: Capgo Metadata", + "step-1-description": "Download or copy the Capgo SAML metadata to configure in your identity provider", + "step-2-title": "Step 2: Identity Provider Metadata", + "step-2-description": "Provide your identity provider's SAML metadata URL or XML", + "step-3-title": "Step 3: Configure Domains", + "step-3-description": "Add email domains that should authenticate via SSO", + "step-4-title": "Step 4: Test SSO Connection", + "step-4-description": "Test your SSO configuration before enabling it for your organization", + "step-5-title": "Step 5: Enable SSO", + "step-5-description": "Review your configuration and enable SSO for your organization", + "super-admin-permission-required": "Super admin permission required", + "test": "Test", + "test-connection": "Test Connection", + "test-sso-connection": "Test SSO Connection", + "test-sso-description": "Click the button below to test your SSO configuration. You will be redirected to your identity provider to authenticate", + "verified": "Verified", + "verify-domain": "Verify Domain" +} "manifest": "Manifest", "no-manifest-bundle": "No manifest", "no-zip-bundle": "No zip bundle", diff --git a/playwright/e2e/sso.spec.ts b/playwright/e2e/sso.spec.ts new file mode 100644 index 0000000000..88b1903ae9 --- /dev/null +++ b/playwright/e2e/sso.spec.ts @@ -0,0 +1,266 @@ +import { expect, test } from '../support/commands' + +test.describe('sso configuration wizard', () => { + test.beforeEach(async ({ page }) => { + // Login as admin user + await page.goto('/login/') + await page.fill('[data-test="email"]', 'admin@capgo.app') + await page.fill('[data-test="password"]', 'adminadmin') + await page.click('[data-test="submit"]') + await page.waitForURL('/app') + + // Navigate to SSO settings page + await page.goto('/settings/organization/sso') + }) + + test('should display sso wizard for super_admin', async ({ page }) => { + // Verify wizard is visible + await expect(page.locator('h1')).toContainText('SSO Configuration') + + // Verify step 1 (Capgo metadata) is shown + await expect(page.locator('text=Entity ID')).toBeVisible() + await expect(page.locator('text=ACS URL')).toBeVisible() + }) + + test('should copy capgo metadata to clipboard', async ({ page }) => { + // Click copy button for Entity ID + const entityIdCopyBtn = page.locator('button:has-text("Copy")').first() + await entityIdCopyBtn.click() + + // Verify success toast (if implemented) + // Note: Toast verification depends on implementation + }) + + test('should navigate through wizard steps', async ({ page }) => { + // Step 1: Verify Capgo metadata display + await expect(page.locator('text=Entity ID')).toBeVisible() + + // Click next to go to step 2 + const nextBtn = page.locator('button:has-text("Next")') + await nextBtn.click() + + // Step 2: Verify IdP metadata input + await expect(page.locator('text=Metadata URL')).toBeVisible() + + // Enter metadata URL + await page.fill('input[placeholder*="metadata"]', 'https://example.com/saml/metadata') + + // Click next to go to step 3 + await nextBtn.click() + + // Step 3: Verify domain management + await expect(page.locator('text=Email Domains')).toBeVisible() + }) + + test('should validate metadata input format', async ({ page }) => { + // Go to step 2 + const nextBtn = page.locator('button:has-text("Next")') + await nextBtn.click() + + // Try invalid URL + await page.fill('input[placeholder*="metadata"]', 'not-a-valid-url') + await nextBtn.click() + + // Should show error or stay on same step + await expect(page.locator('text=Metadata URL')).toBeVisible() + }) + + test('should add and remove domains', async ({ page }) => { + // Navigate to step 3 (domain management) + const nextBtn = page.locator('button:has-text("Next")') + + // Step 1 -> 2 + await nextBtn.click() + await page.fill('input[placeholder*="metadata"]', 'https://example.com/saml/metadata') + + // Step 2 -> 3 + await nextBtn.click() + + // Add domain + const domainInput = page.locator('input[placeholder*="domain"]') + await domainInput.fill('testcompany.com') + const addDomainBtn = page.locator('button:has-text("Add Domain")') + await addDomainBtn.click() + + // Verify domain appears in list + await expect(page.locator('text=testcompany.com')).toBeVisible() + + // Remove domain + const removeBtn = page.locator('button[aria-label="Remove domain"]') + await removeBtn.click() + + // Verify domain is removed + await expect(page.locator('text=testcompany.com')).not.toBeVisible() + }) + + test('should require at least one domain before enabling', async ({ page }) => { + // Navigate through all steps without adding domain + const nextBtn = page.locator('button:has-text("Next")') + + // Step 1 -> 2 + await nextBtn.click() + await page.fill('input[placeholder*="metadata"]', 'https://example.com/saml/metadata') + + // Step 2 -> 3 + await nextBtn.click() + + // Try to go to step 4 without domain + await nextBtn.click() + + // Should show error or stay on step 3 + await expect(page.locator('text=Email Domains')).toBeVisible() + }) + + test('should show sso status when configuration exists', async ({ page }) => { + // If SSO is already configured, should see status banner + const statusBanner = page.locator('[data-test="sso-status"]') + + // Check if status banner exists + const bannerExists = await statusBanner.count() + + if (bannerExists > 0) { + // Verify banner shows enabled or disabled state + await expect(statusBanner).toContainText(/enabled|disabled/i) + } + }) +}) + +test.describe('sso login flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login/') + }) + + test('should detect sso for configured domain', async ({ page }) => { + // Note: This test requires SSO to be configured for a test domain + // Enter email with SSO domain + const emailInput = page.locator('[data-test="email"]') + await emailInput.fill('user@sso-configured-domain.com') + + // Wait for SSO detection + await page.waitForTimeout(500) + + // Should show SSO banner + const ssoBanner = page.locator('[data-test="sso-banner"]') + const bannerVisible = await ssoBanner.isVisible().catch(() => false) + + // If SSO is configured for this domain, banner should appear + if (bannerVisible) { + await expect(ssoBanner).toContainText('SSO available') + + // Verify SSO button appears + const ssoBtn = page.locator('button:has-text("Continue with SSO")') + await expect(ssoBtn).toBeVisible() + } + }) + + test('should not detect sso for public email domains', async ({ page }) => { + // Public domains should not trigger SSO + const publicDomains = ['gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com'] + + for (const domain of publicDomains) { + await page.reload() + const emailInput = page.locator('[data-test="email"]') + await emailInput.fill(`user@${domain}`) + + // Wait for detection + await page.waitForTimeout(500) + + // Should not show SSO banner + const ssoBanner = page.locator('[data-test="sso-banner"]') + await expect(ssoBanner).not.toBeVisible() + } + }) + + test('should show password login option when sso is available', async ({ page }) => { + // Even with SSO, users should be able to use password + const emailInput = page.locator('[data-test="email"]') + await emailInput.fill('user@example.com') + + // Wait for detection + await page.waitForTimeout(500) + + // Password input and login button should always be available + const passwordInput = page.locator('[data-test="password"]') + const loginBtn = page.locator('[data-test="submit"]') + + await expect(passwordInput).toBeVisible() + await expect(loginBtn).toBeVisible() + }) +}) + +test.describe('sso permission checks', () => { + test('should hide sso tab for non-super_admin users', async ({ page }) => { + // Login as regular test user (not super_admin) + await page.goto('/login/') + await page.fill('[data-test="email"]', 'test@capgo.app') + await page.fill('[data-test="password"]', 'testtest') + await page.click('[data-test="submit"]') + await page.waitForURL('/app') + + // Try to navigate to organization settings + await page.goto('/settings/organization') + + // SSO tab should not be visible + const ssoTab = page.locator('a[href*="/sso"]') + await expect(ssoTab).not.toBeVisible() + }) + + test('should redirect non-super_admin from sso page', async ({ page }) => { + // Login as regular user + await page.goto('/login/') + await page.fill('[data-test="email"]', 'test@capgo.app') + await page.fill('[data-test="password"]', 'testtest') + await page.click('[data-test="submit"]') + await page.waitForURL('/app') + + // Try to directly access SSO page + await page.goto('/settings/organization/sso') + + // Should be redirected or show permission error + await page.waitForTimeout(1000) + const currentUrl = page.url() + const isSSOPage = currentUrl.includes('/sso') + + if (isSSOPage) { + // Should show permission error + await expect(page.locator('text=permission')).toBeVisible() + } + else { + // Should be redirected away + expect(isSSOPage).toBe(false) + } + }) + + test('should allow super_admin to access sso page', async ({ page }) => { + // Login as admin user + await page.goto('/login/') + await page.fill('[data-test="email"]', 'admin@capgo.app') + await page.fill('[data-test="password"]', 'adminadmin') + await page.click('[data-test="submit"]') + await page.waitForURL('/app') + + // Navigate to SSO page + await page.goto('/settings/organization/sso') + + // Should see SSO configuration wizard + await expect(page.locator('h1')).toContainText('SSO') + }) +}) + +test.describe('sso audit logging', () => { + test('should log sso configuration views', async ({ page }) => { + // Login as admin + await page.goto('/login/') + await page.fill('[data-test="email"]', 'admin@capgo.app') + await page.fill('[data-test="password"]', 'adminadmin') + await page.click('[data-test="submit"]') + await page.waitForURL('/app') + + // View SSO page + await page.goto('/settings/organization/sso') + + // Audit log should be created in database + // This is verified in backend tests, frontend just needs to not error + await expect(page.locator('h1')).toContainText('SSO') + }) +}) diff --git a/restart-auth-with-saml-v2.sh b/restart-auth-with-saml-v2.sh new file mode 100755 index 0000000000..5afc08ea02 --- /dev/null +++ b/restart-auth-with-saml-v2.sh @@ -0,0 +1,98 @@ +#!/bin/bash +set -e + +# Stop and remove existing auth container +docker stop supabase_auth_capgo-app 2>/dev/null || true +docker rm supabase_auth_capgo-app 2>/dev/null || true + +# Create base64 single-line encoded keys +cat /tmp/saml-key-pkcs1.pem | base64 | tr -d '\n' > /tmp/saml-key-b64.txt +cat /tmp/saml-cert.pem | base64 | tr -d '\n' > /tmp/saml-cert-b64.txt + +# Read into variables +SAML_KEY_B64=$(cat /tmp/saml-key-b64.txt) +SAML_CERT_B64=$(cat /tmp/saml-cert-b64.txt) + +echo "Starting auth container with SAML..." +echo "Key length: ${#SAML_KEY_B64}" +echo "Cert length: ${#SAML_CERT_B64}" + +# Start container with all environment variables +docker run -d \ + --name supabase_auth_capgo-app \ + --network supabase_network_capgo-app \ + -e API_EXTERNAL_URL=http://127.0.0.1:54321 \ + -e GOTRUE_API_HOST=0.0.0.0 \ + -e GOTRUE_API_PORT=9999 \ + -e GOTRUE_DB_DRIVER=postgres \ + -e "GOTRUE_DB_DATABASE_URL=postgresql://supabase_auth_admin:postgres@supabase_db_capgo-app:5432/postgres" \ + -e GOTRUE_SITE_URL=http://127.0.0.1:3000 \ + -e GOTRUE_URI_ALLOW_LIST=https://127.0.0.1:3000 \ + -e GOTRUE_DISABLE_SIGNUP=false \ + -e GOTRUE_JWT_ADMIN_ROLES=service_role \ + -e GOTRUE_JWT_AUD=authenticated \ + -e GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated \ + -e GOTRUE_JWT_EXP=3600 \ + -e GOTRUE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long \ + -e GOTRUE_JWT_ISSUER=http://127.0.0.1:54321/auth/v1 \ + -e GOTRUE_EXTERNAL_EMAIL_ENABLED=true \ + -e GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true \ + -e GOTRUE_MAILER_AUTOCONFIRM=true \ + -e GOTRUE_MAILER_OTP_LENGTH=6 \ + -e GOTRUE_MAILER_OTP_EXP=3600 \ + -e GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false \ + -e GOTRUE_SMTP_MAX_FREQUENCY=1s \ + -e GOTRUE_MAILER_URLPATHS_INVITE=http://127.0.0.1:54321/auth/v1/verify \ + -e GOTRUE_MAILER_URLPATHS_CONFIRMATION=http://127.0.0.1:54321/auth/v1/verify \ + -e GOTRUE_MAILER_URLPATHS_RECOVERY=http://127.0.0.1:54321/auth/v1/verify \ + -e GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=http://127.0.0.1:54321/auth/v1/verify \ + -e GOTRUE_RATE_LIMIT_EMAIL_SENT=360000 \ + -e GOTRUE_EXTERNAL_PHONE_ENABLED=false \ + -e GOTRUE_SMS_AUTOCONFIRM=true \ + -e GOTRUE_SMS_MAX_FREQUENCY=5s \ + -e GOTRUE_SMS_OTP_EXP=6000 \ + -e GOTRUE_SMS_OTP_LENGTH=6 \ + -e "GOTRUE_SMS_TEMPLATE=Your code is {{ .Code }}" \ + -e GOTRUE_PASSWORD_MIN_LENGTH=6 \ + -e GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=true \ + -e GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=10 \ + -e GOTRUE_SECURITY_MANUAL_LINKING_ENABLED=false \ + -e GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION=false \ + -e GOTRUE_MFA_PHONE_ENROLL_ENABLED=false \ + -e GOTRUE_MFA_PHONE_VERIFY_ENABLED=false \ + -e GOTRUE_MFA_TOTP_ENROLL_ENABLED=false \ + -e GOTRUE_MFA_TOTP_VERIFY_ENABLED=false \ + -e GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED=false \ + -e GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED=false \ + -e GOTRUE_MFA_MAX_ENROLLED_FACTORS=10 \ + -e GOTRUE_RATE_LIMIT_ANONYMOUS_USERS=30 \ + -e GOTRUE_RATE_LIMIT_TOKEN_REFRESH=150 \ + -e GOTRUE_RATE_LIMIT_OTP=30 \ + -e GOTRUE_RATE_LIMIT_VERIFY=30 \ + -e GOTRUE_RATE_LIMIT_SMS_SENT=30 \ + -e GOTRUE_RATE_LIMIT_WEB3=30 \ + -e GOTRUE_EXTERNAL_APPLE_ENABLED=false \ + -e GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK=false \ + -e GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL=false \ + -e GOTRUE_EXTERNAL_APPLE_REDIRECT_URI=http://127.0.0.1:54321/auth/v1/callback \ + -e GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED=false \ + -e GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED=false \ + -e GOTRUE_DB_MIGRATIONS_PATH=/usr/local/etc/auth/migrations \ + -e GOTRUE_SAML_ENABLED=true \ + -e "GOTRUE_SAML_PRIVATE_KEY=${SAML_KEY_B64}" \ + -e "GOTRUE_SAML_SIGNING_CERT=${SAML_CERT_B64}" \ + -l com.docker.compose.project=capgo-app \ + -l com.supabase.cli.project=capgo-app \ + public.ecr.aws/supabase/gotrue:v2.184.0 + +echo "Waiting for container to start..." +sleep 3 + +if docker ps | grep -q supabase_auth_capgo-app; then + echo "✅ Auth container running with SAML enabled!" + docker logs supabase_auth_capgo-app 2>&1 | tail -5 +else + echo "❌ Auth container failed to start" + docker logs supabase_auth_capgo-app 2>&1 | tail -10 + exit 1 +fi diff --git a/restart-auth-with-saml.sh b/restart-auth-with-saml.sh new file mode 100755 index 0000000000..c66730ab24 --- /dev/null +++ b/restart-auth-with-saml.sh @@ -0,0 +1,83 @@ +#!/bin/bash +set -e + +# Stop and remove existing auth container +docker stop supabase_auth_capgo-app 2>/dev/null || true +docker rm supabase_auth_capgo-app 2>/dev/null || true + +# Create base64 encoded keys in files (single line) +cat /tmp/saml-key-pkcs1.pem | base64 | tr -d '\n' > /tmp/saml-key-b64.txt +cat /tmp/saml-cert.pem | base64 | tr -d '\n' > /tmp/saml-cert-b64.txt + +# Start auth container with SAML enabled, mounting key files +docker run -d \ + --name supabase_auth_capgo-app \ + --network supabase_network_capgo-app \ + -v /tmp/saml-key-b64.txt:/tmp/saml-key.txt:ro \ + -v /tmp/saml-cert-b64.txt:/tmp/saml-cert.txt:ro \ + -e API_EXTERNAL_URL="http://127.0.0.1:54321" \ + -e GOTRUE_API_HOST="0.0.0.0" \ + -e GOTRUE_API_PORT="9999" \ + -e GOTRUE_DB_DRIVER="postgres" \ + -e GOTRUE_DB_DATABASE_URL="postgresql://supabase_auth_admin:postgres@supabase_db_capgo-app:5432/postgres" \ + -e GOTRUE_SITE_URL="http://127.0.0.1:3000" \ + -e GOTRUE_URI_ALLOW_LIST="https://127.0.0.1:3000" \ + -e GOTRUE_DISABLE_SIGNUP="false" \ + -e GOTRUE_JWT_ADMIN_ROLES="service_role" \ + -e GOTRUE_JWT_AUD="authenticated" \ + -e GOTRUE_JWT_DEFAULT_GROUP_NAME="authenticated" \ + -e GOTRUE_JWT_EXP="3600" \ + -e GOTRUE_JWT_SECRET="super-secret-jwt-token-with-at-least-32-characters-long" \ + -e GOTRUE_JWT_ISSUER="http://127.0.0.1:54321/auth/v1" \ + -e GOTRUE_EXTERNAL_EMAIL_ENABLED="true" \ + -e GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" \ + -e GOTRUE_MAILER_AUTOCONFIRM="true" \ + -e GOTRUE_MAILER_OTP_LENGTH="6" \ + -e GOTRUE_MAILER_OTP_EXP="3600" \ + -e GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" \ + -e GOTRUE_SMTP_MAX_FREQUENCY="1s" \ + -e GOTRUE_MAILER_URLPATHS_INVITE="http://127.0.0.1:54321/auth/v1/verify" \ + -e GOTRUE_MAILER_URLPATHS_CONFIRMATION="http://127.0.0.1:54321/auth/v1/verify" \ + -e GOTRUE_MAILER_URLPATHS_RECOVERY="http://127.0.0.1:54321/auth/v1/verify" \ + -e GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE="http://127.0.0.1:54321/auth/v1/verify" \ + -e GOTRUE_RATE_LIMIT_EMAIL_SENT="360000" \ + -e GOTRUE_EXTERNAL_PHONE_ENABLED="false" \ + -e GOTRUE_SMS_AUTOCONFIRM="true" \ + -e GOTRUE_SMS_MAX_FREQUENCY="5s" \ + -e GOTRUE_SMS_OTP_EXP="6000" \ + -e GOTRUE_SMS_OTP_LENGTH="6" \ + -e GOTRUE_SMS_TEMPLATE="Your code is {{ .Code }}" \ + -e GOTRUE_PASSWORD_MIN_LENGTH="6" \ + -e GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED="true" \ + -e GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL="10" \ + -e GOTRUE_SECURITY_MANUAL_LINKING_ENABLED="false" \ + -e GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION="false" \ + -e GOTRUE_MFA_PHONE_ENROLL_ENABLED="false" \ + -e GOTRUE_MFA_PHONE_VERIFY_ENABLED="false" \ + -e GOTRUE_MFA_TOTP_ENROLL_ENABLED="false" \ + -e GOTRUE_MFA_TOTP_VERIFY_ENABLED="false" \ + -e GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED="false" \ + -e GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED="false" \ + -e GOTRUE_MFA_MAX_ENROLLED_FACTORS="10" \ + -e GOTRUE_RATE_LIMIT_ANONYMOUS_USERS="30" \ + -e GOTRUE_RATE_LIMIT_TOKEN_REFRESH="150" \ + -e GOTRUE_RATE_LIMIT_OTP="30" \ + -e GOTRUE_RATE_LIMIT_VERIFY="30" \ + -e GOTRUE_RATE_LIMIT_SMS_SENT="30" \ + -e GOTRUE_RATE_LIMIT_WEB3="30" \ + -e GOTRUE_EXTERNAL_APPLE_ENABLED="false" \ + -e GOTRUE_EXTERNAL_APPLE_SKIP_NONCE_CHECK="false" \ + -e GOTRUE_EXTERNAL_APPLE_EMAIL_OPTIONAL="false" \ + -e GOTRUE_EXTERNAL_APPLE_REDIRECT_URI="http://127.0.0.1:54321/auth/v1/callback" \ + -e GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED="false" \ + -e GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED="false" \ + -e GOTRUE_DB_MIGRATIONS_PATH="/usr/local/etc/auth/migrations" \ + -e GOTRUE_SAML_ENABLED="true" \ + -e GOTRUE_SAML_PRIVATE_KEY="file:///tmp/saml-key.txt" \ + -e GOTRUE_SAML_SIGNING_CERT="file:///tmp/saml-cert.txt" \ + -l com.docker.compose.project=capgo-app \ + -l com.supabase.cli.project=capgo-app \ + public.ecr.aws/supabase/gotrue:v2.184.0 + +echo "Auth container restarted with SAML enabled" +docker ps | grep supabase_auth diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 96a7aeb84f..64615fe87c 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -241,6 +241,7 @@ declare global { const useResizeObserver: typeof import('@vueuse/core').useResizeObserver const useRoute: typeof import('vue-router').useRoute const useRouter: typeof import('vue-router').useRouter + const useSSODetection: typeof import('./composables/useSSODetection').useSSODetection const useSSRWidth: typeof import('@vueuse/core').useSSRWidth const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea @@ -575,6 +576,7 @@ declare module 'vue' { readonly useResizeObserver: UnwrapRef readonly useRoute: UnwrapRef readonly useRouter: UnwrapRef + readonly useSSODetection: UnwrapRef readonly useSSRWidth: UnwrapRef readonly useScreenOrientation: UnwrapRef readonly useScreenSafeArea: UnwrapRef diff --git a/src/composables/useSSODetection.ts b/src/composables/useSSODetection.ts new file mode 100644 index 0000000000..3b95909409 --- /dev/null +++ b/src/composables/useSSODetection.ts @@ -0,0 +1,168 @@ +/** + * SSO Detection Composable + * + * Detects if an email domain has SSO enabled and provides methods + * to initiate SSO authentication flow. + * + * Usage: + * ```ts + * const { checkSSO, ssoAvailable, initiateSSO } = useSSODetection() + * await checkSSO('user@company.com') + * if (ssoAvailable.value) { + * await initiateSSO() + * } + * ``` + */ + +import { ref } from 'vue' +import { useSupabase } from '~/services/supabase' + +export function useSSODetection() { + const supabase = useSupabase() + const ssoAvailable = ref(false) + const ssoProviderId = ref(null) + const ssoEntityId = ref(null) + const isCheckingSSO = ref(false) + const emailDomain = ref(null) + + /** + * Extract domain from email address + */ + function extractDomain(email: string): string { + return email.split('@')[1]?.toLowerCase() || '' + } + + /** + * Check if SSO is available for the given email domain + * Calls the database function lookup_sso_provider_by_domain + */ + async function checkSSO(email: string): Promise { + if (!email || !email.includes('@')) { + ssoAvailable.value = false + return false + } + + const domain = extractDomain(email) + emailDomain.value = domain + + // Don't check for public email providers + const publicDomains = ['gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com'] + if (publicDomains.includes(domain)) { + ssoAvailable.value = false + return false + } + + isCheckingSSO.value = true + + try { + // Use public backend endpoint that doesn't require authentication + const apiUrl = import.meta.env.VITE_SUPABASE_URL?.replace('/rest/v1', '') || '' + const response = await fetch(`${apiUrl}/functions/v1/sso_check`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }) + + if (!response.ok) { + ssoAvailable.value = false + return false + } + + const result = await response.json() as { available: boolean, provider_id?: string, entity_id?: string } + + if (result.available && result.provider_id) { + ssoAvailable.value = true + ssoProviderId.value = result.provider_id + ssoEntityId.value = result.entity_id || null + return true + } + + ssoAvailable.value = false + return false + } + catch { + ssoAvailable.value = false + return false + } + finally { + isCheckingSSO.value = false + } + } + + /** + * Initiate SSO authentication flow + * Redirects user to IdP for authentication + * + * In production: Redirects to Okta SAML IdP + * In local dev: Redirects to mock SSO endpoint + */ + async function initiateSSO(redirectTo?: string, email?: string): Promise { + if (!ssoProviderId.value) + return + + try { + // Check if Supabase URL is local (meaning truly local testing) + const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || '' + const isLocalSupabase = supabaseUrl.includes('localhost') || supabaseUrl.includes('127.0.0.1') + + if (isLocalSupabase && email) { + // Use mock SSO endpoint ONLY when Supabase is local + const relayState = redirectTo || '/dashboard' + const mockSSOUrl = `${supabaseUrl}/functions/v1/mock-sso-callback?email=${encodeURIComponent(email)}&RelayState=${encodeURIComponent(relayState)}` + + window.location.href = mockSSOUrl + return + } + + // Production/Development/Preprod: Use real Supabase SAML SSO + const options: any = { + provider: 'saml', + options: { + providerId: ssoProviderId.value, + }, + } + + // Add redirect URL if provided + if (redirectTo) { + options.options.redirectTo = `${window.location.origin}${redirectTo}` + } + + const { data, error } = await supabase.auth.signInWithSSO(options) + + if (error) + throw error + + // Redirect to SSO provider (Okta) + if (data?.url) { + window.location.href = data.url + } + } + catch (error) { + console.error('Exception initiating SSO:', error) + throw error + } + } + + /** + * Reset SSO detection state + */ + function reset() { + ssoAvailable.value = false + ssoProviderId.value = null + ssoEntityId.value = null + emailDomain.value = null + } + + return { + ssoAvailable, + ssoProviderId, + ssoEntityId, + isCheckingSSO, + emailDomain, + checkSSO, + initiateSSO, + reset, + } +} diff --git a/src/constants/organizationTabs.ts b/src/constants/organizationTabs.ts index 79d6d33ab0..56f2f20f8f 100644 --- a/src/constants/organizationTabs.ts +++ b/src/constants/organizationTabs.ts @@ -5,11 +5,14 @@ import IconPlan from '~icons/heroicons/credit-card' import IconCredits from '~icons/heroicons/currency-dollar' import IconWebhook from '~icons/heroicons/globe-alt' import IconInfo from '~icons/heroicons/information-circle' -import IconSecurity from '~icons/heroicons/shield-check' +import IconShield from '~icons/heroicons/shield-check' import IconUsers from '~icons/heroicons/users' +const IconSecurity = IconShield + export const organizationTabs: Tab[] = [ { label: 'general', key: '/settings/organization', icon: IconInfo }, + { label: 'sso', key: '/settings/organization/sso', icon: IconShield }, { label: 'members', key: '/settings/organization/members', icon: IconUsers }, // Security tab is added dynamically in settings.vue for super_admins only { label: 'security', key: '/settings/organization/security', icon: IconSecurity }, diff --git a/src/layouts/settings.vue b/src/layouts/settings.vue index 9526e30202..f64e09fa81 100644 --- a/src/layouts/settings.vue +++ b/src/layouts/settings.vue @@ -36,74 +36,79 @@ const shouldBlockContent = computed(() => { const organizationTabs = ref([...baseOrgTabs]) as Ref watchEffect(() => { - // ensure usage/plans tabs based on permissions (keeps icons from base) - const needsUsage = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) - const hasUsage = organizationTabs.value.find(tab => tab.key === '/settings/organization/usage') - if (needsUsage && !hasUsage) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/usage') - if (base) - organizationTabs.value.push({ ...base }) + // Rebuild tabs array in correct order based on permissions + const newTabs: Tab[] = [] + + // Always show general and members + const general = baseOrgTabs.find(t => t.key === '/settings/organization') + const members = baseOrgTabs.find(t => t.key === '/settings/organization/members') + if (general) + newTabs.push({ ...general }) + + // Add SSO after general if user is super_admin + const needsSSO = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) + if (needsSSO) { + const sso = baseOrgTabs.find(t => t.key === '/settings/organization/sso') + if (sso) + newTabs.push({ ...sso }) } - if (!needsUsage && hasUsage) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/usage') - const needsCredits = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) - const hasCredits = organizationTabs.value.find(tab => tab.key === '/settings/organization/credits') + // Add members + if (members) + newTabs.push({ ...members }) - if (needsCredits && !hasCredits) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/credits') - if (base) - organizationTabs.value.push({ ...base }) + // Add plans if super_admin + const needsPlans = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) + if (needsPlans) { + const plans = baseOrgTabs.find(t => t.key === '/settings/organization/plans') + if (plans) + newTabs.push({ ...plans }) } - if (!needsCredits && hasCredits) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/credits') + // Add usage if super_admin + const needsUsage = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) + if (needsUsage) { + const usage = baseOrgTabs.find(t => t.key === '/settings/organization/usage') + if (usage) + newTabs.push({ ...usage }) + } - const needsPlans = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) - const hasPlans = organizationTabs.value.find(tab => tab.key === '/settings/organization/plans') - if (needsPlans && !hasPlans) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/plans') - if (base) - organizationTabs.value.push({ ...base }) + // Add credits if super_admin + const needsCredits = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) + if (needsCredits) { + const credits = baseOrgTabs.find(t => t.key === '/settings/organization/credits') + if (credits) + newTabs.push({ ...credits }) } - if (!needsPlans && hasPlans) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/plans') - // Audit logs - visible only to super_admins + // Add audit logs if super_admin const needsAuditLogs = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) - const hasAuditLogs = organizationTabs.value.find(tab => tab.key === '/settings/organization/audit-logs') - if (needsAuditLogs && !hasAuditLogs) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/audit-logs') - if (base) - organizationTabs.value.push({ ...base }) + if (needsAuditLogs) { + const auditLogs = baseOrgTabs.find(t => t.key === '/settings/organization/audit-logs') + if (auditLogs) + newTabs.push({ ...auditLogs }) } - if (!needsAuditLogs && hasAuditLogs) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/audit-logs') - // Security - visible only to super_admins + // Add security if super_admin const needsSecurity = organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) - const hasSecurity = organizationTabs.value.find(tab => tab.key === '/settings/organization/security') - if (needsSecurity && !hasSecurity) { - const base = baseOrgTabs.find(t => t.key === '/settings/organization/security') - if (base) - organizationTabs.value.push({ ...base }) + if (needsSecurity) { + const security = baseOrgTabs.find(t => t.key === '/settings/organization/security') + if (security) + newTabs.push({ ...security }) } - if (!needsSecurity && hasSecurity) - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/security') + // Add billing if super_admin and not native platform if (!Capacitor.isNativePlatform() - && organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin']) - && !organizationTabs.value.find(tab => tab.key === '/billing')) { - organizationTabs.value.push({ + && organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin'])) { + newTabs.push({ label: 'billing', icon: IconBilling, key: '/billing', onClick: () => openPortal(organizationStore.currentOrganization?.gid ?? '', t), }) } - else if (!organizationStore.hasPermissionsInRole(organizationStore.currentRole, ['super_admin'])) { - organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/billing') - } + + organizationTabs.value = newTabs }) const activePrimary = computed(() => { diff --git a/src/main.ts b/src/main.ts index 2338f1d5d3..1861ab2271 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,7 +51,7 @@ window.addEventListener('unhandledrejection', (event) => { } }) -const guestPath = ['/login', '/delete_account', '/confirm-signup', '/forgot_password', '/resend_email', '/onboarding', '/register', '/invitation', '/scan'] +const guestPath = ['/login', '/delete_account', '/confirm-signup', '/forgot_password', '/resend_email', '/onboarding', '/register', '/invitation', '/scan', '/sso-login'] getRemoteConfig() const app = createApp(App) diff --git a/src/pages/login.vue b/src/pages/login.vue index c131b6bcbd..02d05db7b9 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -30,6 +30,9 @@ const router = useRouter() const { t } = useI18n() const captchaComponent = ref | null>(null) +// Detect if user is being redirected from SSO +const isFromSSO = ref(false) + const version = import.meta.env.VITE_APP_VERSION const registerUrl = window.location.host === 'console.capgo.app' ? 'https://capgo.app/register/' : `/register/` @@ -263,6 +266,13 @@ async function checkLogin() { const params = new URLSearchParams(parsedUrl.search) const accessToken = params.get('access_token') const refreshToken = params.get('refresh_token') + const fromSSO = params.get('from_sso') + + // Set SSO redirect state + if (fromSSO === 'true') { + isFromSSO.value = true + isLoading.value = true + } if (!!accessToken && !!refreshToken) { const res = await supabase.auth.setSession({ @@ -271,6 +281,8 @@ async function checkLogin() { }) if (res.error) { console.error('Cannot set auth', res.error) + isFromSSO.value = false + isLoading.value = false return } nextLogin() @@ -327,12 +339,36 @@ onMounted(checkLogin) Capgo

! -

+ + +

+
+ + + + +

+ {{ t('sso-signing-in', 'Signing you in...') }} +

+
+
+ + +

{{ t('login-to-your-account') }}

-
+
@@ -375,32 +411,50 @@ onMounted(checkLogin)
- -
-

- {{ version }} -

- -
- - {{ t('forgot') }} {{ t('password') }} ? - -
-
+ + +
+
+ {{ t('or') }} +
+
+ + + + + + {{ t('sign-in-with-sso', 'Sign in with SSO') }} + + +
+

+ {{ version }} +

+ +
+ + {{ t('forgot') }} {{ t('password') }} ? + +
+
@@ -415,7 +469,7 @@ onMounted(checkLogin)
-
+
diff --git a/src/pages/settings/organization/sso.vue b/src/pages/settings/organization/sso.vue new file mode 100644 index 0000000000..865e45c337 --- /dev/null +++ b/src/pages/settings/organization/sso.vue @@ -0,0 +1,1326 @@ + + +