mirror of
https://github.com/AeThex-Corporation/AeThex-OS.git
synced 2026-04-26 09:37:19 +00:00
Add OAuth 2.0 implementation with secure credential handling
- Implement server-side OAuth handlers for Discord, Roblox, GitHub - Add OAuth routes with state validation and PKCE support - Create comprehensive documentation (setup, rotation, quickstart) - Add .env to .gitignore to protect credentials
This commit is contained in:
parent
fa62b3cef1
commit
773cc74c33
7 changed files with 1448 additions and 1 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -3,4 +3,9 @@ dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
server/public
|
server/public
|
||||||
vite.config.ts.*
|
vite.config.ts.*
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
357
docs/CREDENTIALS_ROTATION.md
Normal file
357
docs/CREDENTIALS_ROTATION.md
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
# Credentials Rotation Guide
|
||||||
|
|
||||||
|
## 🚨 Security Incident Response
|
||||||
|
|
||||||
|
**If credentials are compromised (e.g., accidentally committed to git or shared publicly), follow this guide IMMEDIATELY.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Rotation Priority Order
|
||||||
|
|
||||||
|
### 🔴 CRITICAL (Rotate Immediately)
|
||||||
|
1. **Discord Bot Token** - Full bot control
|
||||||
|
2. **Stripe Secret Key** - Payment processing access
|
||||||
|
3. **GitHub Personal Access Token** - Repository access
|
||||||
|
|
||||||
|
### 🟡 HIGH (Rotate Before Production)
|
||||||
|
4. **Discord Client Secret** - OAuth access
|
||||||
|
5. **Roblox Client Secret** - OAuth access
|
||||||
|
6. **Roblox Open Cloud API Key** - API access
|
||||||
|
7. **Stripe Webhook Secret** - Webhook validation
|
||||||
|
|
||||||
|
### 🟢 MEDIUM (Rotate When Convenient)
|
||||||
|
8. **Discord Public Key** - Webhook signature verification
|
||||||
|
9. **GitHub Client ID/Secret** - OAuth (once registered)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 Discord Credentials Rotation
|
||||||
|
|
||||||
|
### 1. Bot Token
|
||||||
|
**Why:** Full control over bot actions, can read/send messages, access servers
|
||||||
|
|
||||||
|
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
|
2. Select your application
|
||||||
|
3. Navigate to **Bot** section
|
||||||
|
4. Click **Reset Token**
|
||||||
|
5. Copy new token to `.env`:
|
||||||
|
```bash
|
||||||
|
DISCORD_BOT_TOKEN=NEW_TOKEN_HERE
|
||||||
|
```
|
||||||
|
6. Restart your application
|
||||||
|
|
||||||
|
### 2. Client Secret
|
||||||
|
**Why:** Used for OAuth token exchange
|
||||||
|
|
||||||
|
1. In Discord Developer Portal, go to **OAuth2** section
|
||||||
|
2. Click **Reset Secret**
|
||||||
|
3. Copy new secret to `.env`:
|
||||||
|
```bash
|
||||||
|
DISCORD_CLIENT_SECRET=NEW_SECRET_HERE
|
||||||
|
```
|
||||||
|
4. Restart your application
|
||||||
|
|
||||||
|
### 3. Public Key
|
||||||
|
**Why:** Used to verify webhook signatures (less critical but good practice)
|
||||||
|
|
||||||
|
1. In **General Information** section
|
||||||
|
2. Click **Regenerate** next to Public Key
|
||||||
|
3. Update `.env`:
|
||||||
|
```bash
|
||||||
|
DISCORD_PUBLIC_KEY=NEW_KEY_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎲 Roblox Credentials Rotation
|
||||||
|
|
||||||
|
### 1. Client Secret
|
||||||
|
**Why:** Used for OAuth token exchange
|
||||||
|
|
||||||
|
1. Go to [Roblox Creator Dashboard](https://create.roblox.com/dashboard/credentials)
|
||||||
|
2. Find your OAuth 2.0 credential
|
||||||
|
3. Click **Regenerate Secret**
|
||||||
|
4. Copy new secret to `.env`:
|
||||||
|
```bash
|
||||||
|
ROBLOX_CLIENT_SECRET=NEW_SECRET_HERE
|
||||||
|
```
|
||||||
|
5. Restart your application
|
||||||
|
|
||||||
|
### 2. Open Cloud API Key
|
||||||
|
**Why:** Server-to-server API access
|
||||||
|
|
||||||
|
1. In Creator Dashboard, go to **API Keys**
|
||||||
|
2. Find the compromised key
|
||||||
|
3. Click **Delete** to revoke it
|
||||||
|
4. Create new API key with same permissions
|
||||||
|
5. Copy to `.env`:
|
||||||
|
```bash
|
||||||
|
ROBLOX_OPEN_CLOUD_API_KEY=NEW_KEY_HERE
|
||||||
|
```
|
||||||
|
6. Restart your application
|
||||||
|
|
||||||
|
**Note:** Old API key stops working immediately upon deletion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐙 GitHub Credentials Rotation
|
||||||
|
|
||||||
|
### 1. Personal Access Token
|
||||||
|
**Why:** Repository and API access
|
||||||
|
|
||||||
|
1. Go to [GitHub Personal Access Tokens](https://github.com/settings/tokens)
|
||||||
|
2. Find the compromised token
|
||||||
|
3. Click **Delete** to revoke it
|
||||||
|
4. Generate new token:
|
||||||
|
- Click **Generate new token (classic)**
|
||||||
|
- Select same scopes as before
|
||||||
|
- Set expiration (recommend 90 days)
|
||||||
|
5. Copy to `.env`:
|
||||||
|
```bash
|
||||||
|
GITHUB_PERSONAL_ACCESS_TOKEN=NEW_TOKEN_HERE
|
||||||
|
```
|
||||||
|
6. Restart your application
|
||||||
|
|
||||||
|
**Note:** Old token stops working immediately upon deletion.
|
||||||
|
|
||||||
|
### 2. OAuth Client Secret
|
||||||
|
**When you register OAuth app:**
|
||||||
|
|
||||||
|
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||||
|
2. Select your OAuth app
|
||||||
|
3. Click **Generate a new client secret**
|
||||||
|
4. Copy to `.env`:
|
||||||
|
```bash
|
||||||
|
GITHUB_CLIENT_SECRET=NEW_SECRET_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💳 Stripe Credentials Rotation
|
||||||
|
|
||||||
|
### 1. Secret Key
|
||||||
|
**Why:** Full payment processing access - HIGHEST RISK
|
||||||
|
|
||||||
|
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)
|
||||||
|
2. Click **Reveal test key** or **Reveal live key**
|
||||||
|
3. Click **Roll secret key**
|
||||||
|
4. Confirm the rollover
|
||||||
|
5. Copy new key to `.env`:
|
||||||
|
```bash
|
||||||
|
STRIPE_SECRET_KEY=NEW_KEY_HERE
|
||||||
|
```
|
||||||
|
6. **Deploy immediately** - old key has grace period
|
||||||
|
|
||||||
|
**⚠️ Important:**
|
||||||
|
- Old key works for 24-48 hours (grace period)
|
||||||
|
- Deploy new key ASAP to avoid disruption
|
||||||
|
- Test payments after deployment
|
||||||
|
|
||||||
|
### 2. Webhook Secret
|
||||||
|
**Why:** Validates webhook authenticity
|
||||||
|
|
||||||
|
1. Go to **Developers** → **Webhooks**
|
||||||
|
2. Click your webhook endpoint
|
||||||
|
3. Click **Roll secret**
|
||||||
|
4. Copy new secret to `.env`:
|
||||||
|
```bash
|
||||||
|
STRIPE_WEBHOOK_SECRET=NEW_SECRET_HERE
|
||||||
|
```
|
||||||
|
5. Restart your application
|
||||||
|
|
||||||
|
**Note:** Old webhooks will fail signature validation immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Supabase Credentials (If Needed)
|
||||||
|
|
||||||
|
### Anon Key
|
||||||
|
**Lower risk** - designed to be public, but rotation doesn't hurt
|
||||||
|
|
||||||
|
1. Go to [Supabase Dashboard](https://supabase.com/dashboard/project/_/settings/api)
|
||||||
|
2. Navigate to **Settings** → **API**
|
||||||
|
3. Click **Generate new anon key** (if available)
|
||||||
|
4. Update `.env`:
|
||||||
|
```bash
|
||||||
|
SUPABASE_ANON_KEY=NEW_KEY_HERE
|
||||||
|
VITE_SUPABASE_ANON_KEY=NEW_KEY_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Role Key
|
||||||
|
**CRITICAL** - full database access
|
||||||
|
|
||||||
|
1. In Supabase Dashboard, go to **Settings** → **API**
|
||||||
|
2. Click **Rotate service_role key**
|
||||||
|
3. Update server-side env (never expose to client)
|
||||||
|
4. Restart all server instances
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Post-Rotation Checklist
|
||||||
|
|
||||||
|
After rotating credentials:
|
||||||
|
|
||||||
|
### 1. Environment Variables
|
||||||
|
- [ ] Updated `.env` file with all new credentials
|
||||||
|
- [ ] Verified no typos in new keys
|
||||||
|
- [ ] Confirmed `.env` is in `.gitignore`
|
||||||
|
- [ ] Deleted old `.env` backups
|
||||||
|
|
||||||
|
### 2. Application Deployment
|
||||||
|
- [ ] Restarted local development server
|
||||||
|
- [ ] Tested OAuth flows with new credentials
|
||||||
|
- [ ] Verified webhook signatures validate
|
||||||
|
- [ ] Tested API calls work correctly
|
||||||
|
|
||||||
|
### 3. Production Deployment (When Ready)
|
||||||
|
- [ ] Updated production environment variables
|
||||||
|
- [ ] Deployed application with zero downtime
|
||||||
|
- [ ] Monitored logs for authentication errors
|
||||||
|
- [ ] Verified no legacy credential usage
|
||||||
|
|
||||||
|
### 4. Documentation
|
||||||
|
- [ ] Updated internal team docs with new setup
|
||||||
|
- [ ] Documented rotation date in security log
|
||||||
|
- [ ] Set calendar reminder for next rotation (90 days)
|
||||||
|
|
||||||
|
### 5. Access Control
|
||||||
|
- [ ] Removed compromised credentials from all locations:
|
||||||
|
- [ ] Chat logs (can't delete, but rotate makes them useless)
|
||||||
|
- [ ] Clipboard history
|
||||||
|
- [ ] Shell history (`history -c`)
|
||||||
|
- [ ] Git reflog (if accidentally committed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗓️ Rotation Schedule
|
||||||
|
|
||||||
|
### Recommended Rotation Frequency
|
||||||
|
|
||||||
|
| Credential | Frequency | Priority |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| Stripe Secret Key | Every 90 days | 🔴 Critical |
|
||||||
|
| Bot Tokens | Every 90 days | 🔴 Critical |
|
||||||
|
| Personal Access Tokens | Every 90 days | 🟡 High |
|
||||||
|
| OAuth Client Secrets | Every 180 days | 🟡 High |
|
||||||
|
| API Keys | Every 180 days | 🟡 High |
|
||||||
|
| Webhook Secrets | Every 180 days | 🟢 Medium |
|
||||||
|
| Public Keys | Annually | 🟢 Medium |
|
||||||
|
|
||||||
|
### Set Reminders
|
||||||
|
```bash
|
||||||
|
# Add to calendar or use cron job:
|
||||||
|
0 0 1 */3 * * echo "Rotate Stripe/Discord credentials" | mail admin@aethex.app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Git History Cleanup (If Committed)
|
||||||
|
|
||||||
|
**If credentials were accidentally committed to git:**
|
||||||
|
|
||||||
|
### Option 1: BFG Repo-Cleaner (Recommended)
|
||||||
|
```bash
|
||||||
|
# Install BFG
|
||||||
|
brew install bfg # or download from https://rtyley.github.io/bfg-repo-cleaner/
|
||||||
|
|
||||||
|
# Clone a fresh copy
|
||||||
|
git clone --mirror https://github.com/AeThex-Corporation/AeThex-OS.git
|
||||||
|
|
||||||
|
# Remove .env files from history
|
||||||
|
bfg --delete-files .env AeThex-OS.git
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
cd AeThex-OS.git
|
||||||
|
git reflog expire --expire=now --all
|
||||||
|
git gc --prune=now --aggressive
|
||||||
|
|
||||||
|
# Force push (⚠️ DESTRUCTIVE)
|
||||||
|
git push --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: git-filter-repo
|
||||||
|
```bash
|
||||||
|
# Install git-filter-repo
|
||||||
|
pip install git-filter-repo
|
||||||
|
|
||||||
|
# Remove .env from history
|
||||||
|
git filter-repo --path .env --invert-paths
|
||||||
|
|
||||||
|
# Force push
|
||||||
|
git push origin --force --all
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Warning:** Force pushing rewrites history. Coordinate with team!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Emergency Contacts
|
||||||
|
|
||||||
|
If credentials are actively being abused:
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
- **Report abuse:** https://dis.gd/report
|
||||||
|
- **Developer support:** https://discord.com/developers/docs
|
||||||
|
|
||||||
|
### Stripe
|
||||||
|
- **Emergency contact:** https://support.stripe.com/
|
||||||
|
- **Phone support:** Available for paid plans
|
||||||
|
|
||||||
|
### GitHub
|
||||||
|
- **Security incidents:** security@github.com
|
||||||
|
- **Support:** https://support.github.com/
|
||||||
|
|
||||||
|
### Roblox
|
||||||
|
- **Security:** security@roblox.com
|
||||||
|
- **Support:** https://www.roblox.com/support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing After Rotation
|
||||||
|
|
||||||
|
Run these commands to verify new credentials work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test Discord OAuth
|
||||||
|
curl "https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=http://localhost:5000/api/oauth/callback/discord&response_type=code&scope=identify"
|
||||||
|
|
||||||
|
# Test Stripe API
|
||||||
|
curl https://api.stripe.com/v1/balance \
|
||||||
|
-u ${STRIPE_SECRET_KEY}:
|
||||||
|
|
||||||
|
# Test GitHub API
|
||||||
|
curl -H "Authorization: token ${GITHUB_PERSONAL_ACCESS_TOKEN}" \
|
||||||
|
https://api.github.com/user
|
||||||
|
|
||||||
|
# Test Roblox Open Cloud
|
||||||
|
curl -H "x-api-key: ${ROBLOX_OPEN_CLOUD_API_KEY}" \
|
||||||
|
https://apis.roblox.com/cloud/v2/users/${USER_ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Security Best Practices
|
||||||
|
|
||||||
|
### Prevention
|
||||||
|
1. **Never commit credentials** - Use `.env` and add to `.gitignore`
|
||||||
|
2. **Use environment-specific credentials** - Separate dev/staging/prod
|
||||||
|
3. **Rotate proactively** - Don't wait for incidents
|
||||||
|
4. **Monitor usage** - Watch API logs for suspicious activity
|
||||||
|
5. **Least privilege** - Grant minimum permissions needed
|
||||||
|
|
||||||
|
### Detection
|
||||||
|
1. **Enable webhook alerts** - Get notified of unusual API usage
|
||||||
|
2. **Monitor git commits** - Use pre-commit hooks to scan for secrets
|
||||||
|
3. **Audit logs** - Review provider dashboards regularly
|
||||||
|
4. **Automated scanning** - Use tools like `git-secrets` or `trufflehog`
|
||||||
|
|
||||||
|
### Response
|
||||||
|
1. **Have this document ready** - Don't scramble during incidents
|
||||||
|
2. **Test rotation process** - Practice on dev environment first
|
||||||
|
3. **Document incidents** - Learn from mistakes
|
||||||
|
4. **Automate where possible** - Use secret management tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** December 24, 2025
|
||||||
|
**Next Review:** March 24, 2026
|
||||||
307
docs/OAUTH_IMPLEMENTATION.md
Normal file
307
docs/OAUTH_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
# OAuth Implementation Summary
|
||||||
|
|
||||||
|
## ✅ What Was Implemented
|
||||||
|
|
||||||
|
### 1. Server-Side OAuth Handler (`server/oauth-handlers.ts`)
|
||||||
|
- **Purpose:** Secure OAuth 2.0 identity linking for Discord, Roblox, and GitHub
|
||||||
|
- **Security:** Server-side identity verification prevents client-side spoofing
|
||||||
|
- **Features:**
|
||||||
|
- State token validation (5-minute TTL)
|
||||||
|
- PKCE support for Roblox OAuth
|
||||||
|
- Automatic subject/identity creation
|
||||||
|
- Duplicate identity detection
|
||||||
|
- Provider-specific identity mapping
|
||||||
|
|
||||||
|
### 2. OAuth Routes (`server/routes.ts`)
|
||||||
|
Added two new endpoints:
|
||||||
|
- `POST /api/oauth/link/:provider` - Start OAuth flow (get authorization URL)
|
||||||
|
- `GET /api/oauth/callback/:provider` - OAuth callback handler
|
||||||
|
|
||||||
|
### 3. Documentation
|
||||||
|
- **OAuth Setup Guide** (`docs/OAUTH_SETUP.md`)
|
||||||
|
- Provider registration instructions
|
||||||
|
- Redirect URI configuration
|
||||||
|
- Environment variable setup
|
||||||
|
- Testing procedures
|
||||||
|
|
||||||
|
- **Credentials Rotation Guide** (`docs/CREDENTIALS_ROTATION.md`)
|
||||||
|
- Emergency response procedures
|
||||||
|
- Provider-specific rotation steps
|
||||||
|
- Security best practices
|
||||||
|
- Automated rotation schedules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security Improvements
|
||||||
|
|
||||||
|
### Before (Vulnerable)
|
||||||
|
```typescript
|
||||||
|
// Client submits external_id - easily spoofed!
|
||||||
|
POST /api/link { provider: "discord", external_id: "123" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Secure)
|
||||||
|
```typescript
|
||||||
|
// 1. Client requests authorization URL
|
||||||
|
POST /api/oauth/link/discord
|
||||||
|
→ Returns: { authUrl: "https://discord.com/...", state: "..." }
|
||||||
|
|
||||||
|
// 2. User authorizes on Discord
|
||||||
|
→ Redirects to callback with code
|
||||||
|
|
||||||
|
// 3. Server exchanges code for token
|
||||||
|
GET /api/oauth/callback/discord?code=abc&state=xyz
|
||||||
|
→ Server calls Discord API to get real user ID
|
||||||
|
→ Creates identity link with verified ID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Security Features:**
|
||||||
|
- ✅ Server fetches identity from provider (can't be faked)
|
||||||
|
- ✅ State tokens prevent CSRF attacks
|
||||||
|
- ✅ PKCE prevents authorization code interception (Roblox)
|
||||||
|
- ✅ Duplicate identity detection (one provider account = one AeThex account)
|
||||||
|
- ✅ In-memory state storage with automatic cleanup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 How to Use
|
||||||
|
|
||||||
|
### For Users (Frontend Integration)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Start linking flow
|
||||||
|
const response = await fetch(`/api/oauth/link/discord`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include' // Include session cookie
|
||||||
|
});
|
||||||
|
|
||||||
|
const { authUrl, state } = await response.json();
|
||||||
|
|
||||||
|
// 2. Redirect user to provider
|
||||||
|
window.location.href = authUrl;
|
||||||
|
|
||||||
|
// 3. User returns to /settings?oauth=success&provider=discord
|
||||||
|
// Check query params to show success message
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
#### Testing Locally
|
||||||
|
1. Register OAuth apps with localhost redirect URIs:
|
||||||
|
- Discord: `http://localhost:5000/api/oauth/callback/discord`
|
||||||
|
- Roblox: `http://localhost:5000/api/oauth/callback/roblox`
|
||||||
|
- GitHub: `http://localhost:5000/api/oauth/callback/github`
|
||||||
|
|
||||||
|
2. Add credentials to `.env`:
|
||||||
|
```bash
|
||||||
|
DISCORD_CLIENT_ID=...
|
||||||
|
DISCORD_CLIENT_SECRET=...
|
||||||
|
ROBLOX_CLIENT_ID=...
|
||||||
|
ROBLOX_CLIENT_SECRET=...
|
||||||
|
GITHUB_CLIENT_ID=...
|
||||||
|
GITHUB_CLIENT_SECRET=...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start dev server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Test flow:
|
||||||
|
- Log in to AeThex OS
|
||||||
|
- Go to Settings page
|
||||||
|
- Click "Link Discord" button
|
||||||
|
- Authorize on Discord
|
||||||
|
- Verify redirect back with success message
|
||||||
|
- Check database for new `aethex_subject_identities` row
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Database Changes
|
||||||
|
|
||||||
|
### New Records Created
|
||||||
|
|
||||||
|
When a user links Discord account:
|
||||||
|
|
||||||
|
**aethex_subjects** (if first identity):
|
||||||
|
```sql
|
||||||
|
INSERT INTO aethex_subjects (supabase_user_id)
|
||||||
|
VALUES ('uuid-of-supabase-user');
|
||||||
|
```
|
||||||
|
|
||||||
|
**aethex_subject_identities**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO aethex_subject_identities (
|
||||||
|
subject_id,
|
||||||
|
issuer,
|
||||||
|
external_id,
|
||||||
|
external_username,
|
||||||
|
verified,
|
||||||
|
metadata
|
||||||
|
) VALUES (
|
||||||
|
'uuid-of-subject',
|
||||||
|
'discord',
|
||||||
|
'123456789',
|
||||||
|
'username#1234',
|
||||||
|
true,
|
||||||
|
'{"avatar": "...", "email": "...", "verified": true}'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying Linked Identities
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Get all identities for a user
|
||||||
|
SELECT si.*
|
||||||
|
FROM aethex_subject_identities si
|
||||||
|
JOIN aethex_subjects s ON s.id = si.subject_id
|
||||||
|
WHERE s.supabase_user_id = 'user-uuid';
|
||||||
|
|
||||||
|
-- Check if Discord account already linked
|
||||||
|
SELECT s.supabase_user_id
|
||||||
|
FROM aethex_subject_identities si
|
||||||
|
JOIN aethex_subjects s ON s.id = si.subject_id
|
||||||
|
WHERE si.issuer = 'discord'
|
||||||
|
AND si.external_id = '123456789';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Environment Variables Required
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# Discord OAuth
|
||||||
|
DISCORD_CLIENT_ID=your_dev_client_id
|
||||||
|
DISCORD_CLIENT_SECRET=your_dev_client_secret
|
||||||
|
|
||||||
|
# Roblox OAuth
|
||||||
|
ROBLOX_CLIENT_ID=your_dev_client_id
|
||||||
|
ROBLOX_CLIENT_SECRET=your_dev_client_secret
|
||||||
|
|
||||||
|
# GitHub OAuth
|
||||||
|
GITHUB_CLIENT_ID=your_dev_client_id
|
||||||
|
GITHUB_CLIENT_SECRET=your_dev_client_secret
|
||||||
|
|
||||||
|
# Production
|
||||||
|
NODE_ENV=production
|
||||||
|
# ... same variables with production credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redirect URI Logic
|
||||||
|
|
||||||
|
The handler automatically determines the correct redirect URI based on `NODE_ENV`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getRedirectUri(provider: string): string {
|
||||||
|
const baseUrl = process.env.NODE_ENV === "production"
|
||||||
|
? "https://aethex.app"
|
||||||
|
: `http://localhost:${process.env.PORT || 5000}`;
|
||||||
|
|
||||||
|
return `${baseUrl}/api/oauth/callback/${provider}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Checklist
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
- [ ] Start OAuth flow for each provider
|
||||||
|
- [ ] Complete authorization on provider site
|
||||||
|
- [ ] Verify redirect back to AeThex OS
|
||||||
|
- [ ] Check database for new identity record
|
||||||
|
- [ ] Try linking same provider account twice (should succeed, no duplicate)
|
||||||
|
- [ ] Try linking already-linked account to different AeThex user (should fail with 409)
|
||||||
|
- [ ] Test expired state token (wait 5+ minutes before callback)
|
||||||
|
- [ ] Test invalid state parameter (manually edit callback URL)
|
||||||
|
|
||||||
|
### Security Testing
|
||||||
|
- [ ] Cannot link identity without being logged in
|
||||||
|
- [ ] Cannot reuse authorization code (one-time use)
|
||||||
|
- [ ] State token validated and deleted after use
|
||||||
|
- [ ] Provider account can't be linked to multiple AeThex accounts
|
||||||
|
- [ ] Server fetches identity (client can't spoof external_id)
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] User closes browser during OAuth flow
|
||||||
|
- [ ] Network error during token exchange
|
||||||
|
- [ ] Provider API returns invalid response
|
||||||
|
- [ ] User denies authorization on provider site
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### "Invalid redirect_uri"
|
||||||
|
**Cause:** OAuth app redirect URI doesn't match exactly
|
||||||
|
**Fix:**
|
||||||
|
1. Check `.env` has correct `NODE_ENV` value
|
||||||
|
2. Verify OAuth app has correct URI registered
|
||||||
|
3. Ensure no trailing slash differences
|
||||||
|
|
||||||
|
### "Invalid state"
|
||||||
|
**Cause:** State token expired (5 min) or browser started new session
|
||||||
|
**Fix:**
|
||||||
|
1. Start OAuth flow again
|
||||||
|
2. Complete within 5 minutes
|
||||||
|
3. Don't open multiple OAuth flows in parallel
|
||||||
|
|
||||||
|
### "Identity already linked"
|
||||||
|
**Cause:** Provider account linked to different AeThex account
|
||||||
|
**Fix:**
|
||||||
|
1. User must log in to original AeThex account
|
||||||
|
2. Unlink identity from settings (TODO: implement unlink endpoint)
|
||||||
|
3. Try linking again from new account
|
||||||
|
|
||||||
|
### Build errors
|
||||||
|
**Cause:** Missing type declarations or import paths
|
||||||
|
**Fix:**
|
||||||
|
1. Run `npm install` to ensure all dependencies installed
|
||||||
|
2. Check TypeScript errors: `npx tsc --noEmit`
|
||||||
|
3. Verify import paths use relative paths (not `@/` aliases in server)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 TODO / Future Improvements
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
- [ ] Implement unlink endpoint: `DELETE /api/oauth/unlink/:provider`
|
||||||
|
- [ ] Add frontend UI for identity linking (Settings page)
|
||||||
|
- [ ] Redis/database for state storage (replace in-memory Map)
|
||||||
|
- [ ] Rate limiting on OAuth endpoints
|
||||||
|
- [ ] Logging/monitoring for OAuth events
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
- [ ] Refresh token support (for long-lived access)
|
||||||
|
- [ ] Scope customization per provider
|
||||||
|
- [ ] Additional providers (Twitter/X, Google, Steam)
|
||||||
|
- [ ] Admin panel to view linked identities
|
||||||
|
- [ ] Webhook for identity verification events
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
- [ ] OAuth 2.1 compatibility
|
||||||
|
- [ ] Multiple identities per provider (e.g., 2 Discord accounts)
|
||||||
|
- [ ] Identity verification challenges
|
||||||
|
- [ ] Automated credential rotation reminders
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 References
|
||||||
|
|
||||||
|
- [Discord OAuth2 Docs](https://discord.com/developers/docs/topics/oauth2)
|
||||||
|
- [Roblox OAuth 2.0 Guide](https://create.roblox.com/docs/cloud/open-cloud/oauth2-overview)
|
||||||
|
- [GitHub OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps)
|
||||||
|
- [OAuth 2.0 RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||||
|
- [PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implemented:** December 24, 2025
|
||||||
|
**Domain:** aethex.app
|
||||||
|
**Status:** ✅ Ready for testing (requires OAuth app registration)
|
||||||
140
docs/OAUTH_QUICKSTART.md
Normal file
140
docs/OAUTH_QUICKSTART.md
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
# OAuth Quick Start Guide
|
||||||
|
|
||||||
|
## 🚀 Get OAuth Working in 5 Minutes
|
||||||
|
|
||||||
|
### Step 1: Register OAuth Apps (5 min per provider)
|
||||||
|
|
||||||
|
#### Discord
|
||||||
|
1. Go to https://discord.com/developers/applications
|
||||||
|
2. Click "New Application" → Name it "AeThex OS Dev"
|
||||||
|
3. Go to **OAuth2** → Add redirect URI:
|
||||||
|
```
|
||||||
|
http://localhost:5000/api/oauth/callback/discord
|
||||||
|
```
|
||||||
|
4. Copy **Client ID** and **Client Secret** to `.env`
|
||||||
|
|
||||||
|
#### Roblox
|
||||||
|
1. Go to https://create.roblox.com/dashboard/credentials
|
||||||
|
2. Create new **OAuth 2.0** credential
|
||||||
|
3. Add redirect URI:
|
||||||
|
```
|
||||||
|
http://localhost:5000/api/oauth/callback/roblox
|
||||||
|
```
|
||||||
|
4. Select scopes: `openid`, `profile`
|
||||||
|
5. Copy **Client ID** and **Client Secret** to `.env`
|
||||||
|
|
||||||
|
#### GitHub
|
||||||
|
1. Go to https://github.com/settings/developers
|
||||||
|
2. Click **OAuth Apps** → **New OAuth App**
|
||||||
|
3. Fill in:
|
||||||
|
- Name: `AeThex OS Dev`
|
||||||
|
- Homepage: `http://localhost:5000`
|
||||||
|
- Callback URL: `http://localhost:5000/api/oauth/callback/github`
|
||||||
|
4. Copy **Client ID** and **Client Secret** to `.env`
|
||||||
|
|
||||||
|
### Step 2: Verify Environment Variables
|
||||||
|
|
||||||
|
Your `.env` should have:
|
||||||
|
```bash
|
||||||
|
DISCORD_CLIENT_ID=your_discord_client_id
|
||||||
|
DISCORD_CLIENT_SECRET=your_discord_client_secret
|
||||||
|
ROBLOX_CLIENT_ID=your_roblox_client_id
|
||||||
|
ROBLOX_CLIENT_SECRET=your_roblox_client_secret
|
||||||
|
GITHUB_CLIENT_ID=your_github_client_id
|
||||||
|
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Test the OAuth Flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# In browser:
|
||||||
|
# 1. Log in to AeThex OS
|
||||||
|
# 2. Open browser console
|
||||||
|
# 3. Run this code:
|
||||||
|
fetch('/api/oauth/link/discord', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('Auth URL:', data.authUrl);
|
||||||
|
window.location.href = data.authUrl; // Redirects to Discord
|
||||||
|
});
|
||||||
|
|
||||||
|
# 4. Authorize on Discord
|
||||||
|
# 5. You'll be redirected back to /settings?oauth=success&provider=discord
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify Database
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check if identity was created
|
||||||
|
SELECT * FROM aethex_subject_identities
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Next Steps
|
||||||
|
|
||||||
|
### Add Frontend UI
|
||||||
|
Create a button in Settings page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// client/src/pages/settings.tsx
|
||||||
|
async function linkDiscord() {
|
||||||
|
const res = await fetch('/api/oauth/link/discord', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const { authUrl } = await res.json();
|
||||||
|
window.location.href = authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for success on page load
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('oauth') === 'success') {
|
||||||
|
toast.success(`${params.get('provider')} account linked!`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Production (aethex.app)
|
||||||
|
1. Create production OAuth apps with redirect URI:
|
||||||
|
```
|
||||||
|
https://aethex.app/api/oauth/callback/{provider}
|
||||||
|
```
|
||||||
|
2. Add production credentials to production `.env`
|
||||||
|
3. Set `NODE_ENV=production`
|
||||||
|
4. Deploy!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Security Reminders
|
||||||
|
|
||||||
|
**Before Production:**
|
||||||
|
1. ✅ Rotate ALL credentials (see `docs/CREDENTIALS_ROTATION.md`)
|
||||||
|
2. ✅ Use separate OAuth apps for dev/prod
|
||||||
|
3. ✅ Ensure `.env` is in `.gitignore`
|
||||||
|
4. ✅ Enable HTTPS in production
|
||||||
|
5. ✅ Replace in-memory state storage with Redis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Need Help?
|
||||||
|
|
||||||
|
- **Setup issues:** See `docs/OAUTH_SETUP.md`
|
||||||
|
- **Security questions:** See `docs/CREDENTIALS_ROTATION.md`
|
||||||
|
- **Implementation details:** See `docs/OAUTH_IMPLEMENTATION.md`
|
||||||
|
- **Testing OAuth:** See "Testing Checklist" in implementation doc
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** ✅ OAuth handler implemented and ready for testing
|
||||||
|
**Build:** ✅ Compiles successfully
|
||||||
|
**Next:** Register OAuth apps and test the flow!
|
||||||
261
docs/OAUTH_SETUP.md
Normal file
261
docs/OAUTH_SETUP.md
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
# OAuth Provider Setup Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
AeThex OS uses OAuth 2.0 to link external platform identities (Discord, Roblox, GitHub) to user accounts. This guide explains how to configure OAuth applications for each provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Redirect URIs
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
When running locally, use these redirect URIs:
|
||||||
|
|
||||||
|
- **Discord:** `http://localhost:5000/api/oauth/callback/discord`
|
||||||
|
- **Roblox:** `http://localhost:5000/api/oauth/callback/roblox`
|
||||||
|
- **GitHub:** `http://localhost:5000/api/oauth/callback/github`
|
||||||
|
|
||||||
|
### Production Environment
|
||||||
|
For the live site at `aethex.app`, use:
|
||||||
|
|
||||||
|
- **Discord:** `https://aethex.app/api/oauth/callback/discord`
|
||||||
|
- **Roblox:** `https://aethex.app/api/oauth/callback/roblox`
|
||||||
|
- **GitHub:** `https://aethex.app/api/oauth/callback/github`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 Discord OAuth App Setup
|
||||||
|
|
||||||
|
### 1. Create OAuth Application
|
||||||
|
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||||
|
2. Click "New Application" and name it (e.g., "AeThex OS")
|
||||||
|
3. Navigate to **OAuth2** in the left sidebar
|
||||||
|
|
||||||
|
### 2. Configure Redirects
|
||||||
|
Add these redirect URIs:
|
||||||
|
```
|
||||||
|
http://localhost:5000/api/oauth/callback/discord
|
||||||
|
https://aethex.app/api/oauth/callback/discord
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure Scopes
|
||||||
|
Required OAuth2 scopes:
|
||||||
|
- `identify` - Access user ID and username
|
||||||
|
- `email` - Access user email (optional but recommended)
|
||||||
|
|
||||||
|
### 4. Get Credentials
|
||||||
|
Copy from the OAuth2 page:
|
||||||
|
- **Client ID** → `DISCORD_CLIENT_ID`
|
||||||
|
- **Client Secret** → `DISCORD_CLIENT_SECRET`
|
||||||
|
|
||||||
|
Get from the **General Information** page:
|
||||||
|
- **Public Key** → `DISCORD_PUBLIC_KEY`
|
||||||
|
|
||||||
|
### 5. Bot Token (Optional)
|
||||||
|
If using Discord bot features, go to **Bot** section:
|
||||||
|
- **Token** → `DISCORD_BOT_TOKEN`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎲 Roblox OAuth App Setup
|
||||||
|
|
||||||
|
### 1. Create OAuth Application
|
||||||
|
1. Go to [Roblox Creator Dashboard](https://create.roblox.com/dashboard/credentials)
|
||||||
|
2. Create a new **OAuth 2.0** credential
|
||||||
|
3. Select "Read" access to user profile information
|
||||||
|
|
||||||
|
### 2. Configure Redirects
|
||||||
|
Add these redirect URIs:
|
||||||
|
```
|
||||||
|
http://localhost:5000/api/oauth/callback/roblox
|
||||||
|
https://aethex.app/api/oauth/callback/roblox
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure Scopes
|
||||||
|
Required scopes:
|
||||||
|
- `openid` - OpenID Connect authentication
|
||||||
|
- `profile` - Access to profile information
|
||||||
|
|
||||||
|
### 4. Get Credentials
|
||||||
|
Copy from credentials page:
|
||||||
|
- **Client ID** → `ROBLOX_CLIENT_ID`
|
||||||
|
- **Client Secret** → `ROBLOX_CLIENT_SECRET`
|
||||||
|
|
||||||
|
### 5. Open Cloud API Key (Optional)
|
||||||
|
For server-to-server API calls:
|
||||||
|
1. Go to [Open Cloud](https://create.roblox.com/dashboard/credentials)
|
||||||
|
2. Create new API key with required permissions
|
||||||
|
3. Copy key → `ROBLOX_OPEN_CLOUD_API_KEY`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐙 GitHub OAuth App Setup
|
||||||
|
|
||||||
|
### 1. Create OAuth Application
|
||||||
|
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||||
|
2. Click **OAuth Apps** → **New OAuth App**
|
||||||
|
3. Fill in application details:
|
||||||
|
- **Application name:** AeThex OS
|
||||||
|
- **Homepage URL:** `https://aethex.app`
|
||||||
|
- **Authorization callback URL:** (see below)
|
||||||
|
|
||||||
|
### 2. Configure Redirect URI
|
||||||
|
Use ONE of these (GitHub only allows one per app):
|
||||||
|
|
||||||
|
**For Development:**
|
||||||
|
```
|
||||||
|
http://localhost:5000/api/oauth/callback/github
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Production:**
|
||||||
|
```
|
||||||
|
https://aethex.app/api/oauth/callback/github
|
||||||
|
```
|
||||||
|
|
||||||
|
**💡 Best Practice:** Create TWO separate OAuth apps:
|
||||||
|
- `AeThex OS (Development)` for localhost
|
||||||
|
- `AeThex OS` for production
|
||||||
|
|
||||||
|
### 3. Get Credentials
|
||||||
|
Copy from OAuth app page:
|
||||||
|
- **Client ID** → `GITHUB_CLIENT_ID`
|
||||||
|
- **Client Secret** → `GITHUB_CLIENT_SECRET`
|
||||||
|
|
||||||
|
### 4. Personal Access Token (Optional)
|
||||||
|
For server-to-server API calls:
|
||||||
|
1. Go to [Personal Access Tokens](https://github.com/settings/tokens)
|
||||||
|
2. Generate new token (classic)
|
||||||
|
3. Select required scopes (e.g., `read:user`)
|
||||||
|
4. Copy token → `GITHUB_PERSONAL_ACCESS_TOKEN`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💳 Stripe Webhook Setup
|
||||||
|
|
||||||
|
### 1. Get API Keys
|
||||||
|
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)
|
||||||
|
2. Copy keys:
|
||||||
|
- **Secret key** → `STRIPE_SECRET_KEY`
|
||||||
|
|
||||||
|
### 2. Configure Webhooks
|
||||||
|
1. Go to **Developers** → **Webhooks**
|
||||||
|
2. Click **Add endpoint**
|
||||||
|
3. Set endpoint URL:
|
||||||
|
- Dev: `http://localhost:5000/api/webhooks/stripe`
|
||||||
|
- Prod: `https://aethex.app/api/webhooks/stripe`
|
||||||
|
4. Select events to listen for
|
||||||
|
5. Copy **Signing secret** → `STRIPE_WEBHOOK_SECRET`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Environment Variables
|
||||||
|
|
||||||
|
Add all credentials to `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Discord OAuth
|
||||||
|
DISCORD_CLIENT_ID=your_client_id
|
||||||
|
DISCORD_CLIENT_SECRET=your_client_secret
|
||||||
|
DISCORD_PUBLIC_KEY=your_public_key
|
||||||
|
DISCORD_BOT_TOKEN=your_bot_token
|
||||||
|
|
||||||
|
# GitHub OAuth
|
||||||
|
GITHUB_CLIENT_ID=your_client_id
|
||||||
|
GITHUB_CLIENT_SECRET=your_client_secret
|
||||||
|
GITHUB_PERSONAL_ACCESS_TOKEN=your_pat
|
||||||
|
|
||||||
|
# Roblox OAuth
|
||||||
|
ROBLOX_CLIENT_ID=your_client_id
|
||||||
|
ROBLOX_CLIENT_SECRET=your_client_secret
|
||||||
|
ROBLOX_OPEN_CLOUD_API_KEY=your_api_key
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY=your_secret_key
|
||||||
|
STRIPE_WEBHOOK_SECRET=your_webhook_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Testing OAuth Flow
|
||||||
|
|
||||||
|
### 1. Start Development Server
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Identity Linking
|
||||||
|
1. Log in to AeThex OS
|
||||||
|
2. Go to **Settings** page
|
||||||
|
3. Click "Link Discord" (or other provider)
|
||||||
|
4. Authorize on provider's page
|
||||||
|
5. Verify redirect back to AeThex OS
|
||||||
|
6. Check database for new `subject_identities` entry
|
||||||
|
|
||||||
|
### 3. Verify Security
|
||||||
|
- ✅ State parameter validated
|
||||||
|
- ✅ PKCE challenge verified (Roblox)
|
||||||
|
- ✅ Identity fetched server-side (not trusted from client)
|
||||||
|
- ✅ Duplicate identity detection working
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Security Checklist
|
||||||
|
|
||||||
|
- [ ] Redirect URIs match exactly (trailing slash matters!)
|
||||||
|
- [ ] Client secrets stored in `.env`, never committed to git
|
||||||
|
- [ ] State tokens expire after 5 minutes
|
||||||
|
- [ ] HTTPS enforced in production
|
||||||
|
- [ ] PKCE used for Roblox OAuth
|
||||||
|
- [ ] Server-side identity verification (no client-provided IDs)
|
||||||
|
- [ ] Duplicate identity linking prevented
|
||||||
|
- [ ] Error messages don't leak sensitive info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Multi-Environment Strategy
|
||||||
|
|
||||||
|
### Option 1: Environment-Specific Apps (Recommended)
|
||||||
|
Create separate OAuth apps for each environment:
|
||||||
|
- `AeThex OS Dev` → localhost redirects
|
||||||
|
- `AeThex OS Staging` → staging.aethex.app redirects
|
||||||
|
- `AeThex OS` → aethex.app redirects
|
||||||
|
|
||||||
|
Use different `.env` files for each environment.
|
||||||
|
|
||||||
|
### Option 2: Multiple Redirect URIs
|
||||||
|
Register all redirect URIs in a single app:
|
||||||
|
- Most providers allow multiple redirect URIs
|
||||||
|
- GitHub only allows one (requires separate apps)
|
||||||
|
- Use environment variables to select correct URI at runtime
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support Links
|
||||||
|
|
||||||
|
- **Discord Developer Portal:** https://discord.com/developers/applications
|
||||||
|
- **Roblox Creator Dashboard:** https://create.roblox.com/dashboard/credentials
|
||||||
|
- **GitHub Developer Settings:** https://github.com/settings/developers
|
||||||
|
- **Stripe Dashboard:** https://dashboard.stripe.com/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### "Invalid redirect_uri" error
|
||||||
|
- Verify URI matches EXACTLY (no trailing slash difference)
|
||||||
|
- Check environment variable is set correctly
|
||||||
|
- Ensure OAuth app has URI registered
|
||||||
|
|
||||||
|
### "Invalid state" error
|
||||||
|
- State token expired (5 min limit)
|
||||||
|
- User started flow in different session
|
||||||
|
- Clear browser cache and try again
|
||||||
|
|
||||||
|
### "Identity already linked" error
|
||||||
|
- Provider account linked to different AeThex account
|
||||||
|
- User must unlink from original account first
|
||||||
|
- Check `subject_identities` table for conflicts
|
||||||
|
|
||||||
|
### Token exchange fails
|
||||||
|
- Verify client secret is correct
|
||||||
|
- Check provider's API status page
|
||||||
|
- Ensure code hasn't expired (1-time use, 10 min limit)
|
||||||
368
server/oauth-handlers.ts
Normal file
368
server/oauth-handlers.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { supabase } from "./supabase";
|
||||||
|
|
||||||
|
// Extend Express Request type to include user
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth State Management (in-memory for now, use Redis in production)
|
||||||
|
const oauthStates = new Map<string, {
|
||||||
|
userId: string;
|
||||||
|
provider: string;
|
||||||
|
codeVerifier?: string;
|
||||||
|
createdAt: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Clean up expired states (5 min TTL)
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
|
oauthStates.forEach((data, state) => {
|
||||||
|
if (now - data.createdAt > 300000) {
|
||||||
|
keysToDelete.push(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
keysToDelete.forEach(key => oauthStates.delete(key));
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start OAuth linking flow
|
||||||
|
* Client calls this to get authorization URL
|
||||||
|
*/
|
||||||
|
export async function startOAuthLinking(req: Request, res: Response) {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const userId = req.session?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["discord", "roblox", "github"].includes(provider)) {
|
||||||
|
return res.status(400).json({ error: "Invalid provider" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate state token
|
||||||
|
const state = crypto.randomUUID();
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
// Store state
|
||||||
|
oauthStates.set(state, {
|
||||||
|
userId,
|
||||||
|
provider,
|
||||||
|
codeVerifier,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build authorization URL
|
||||||
|
const redirectUri = getRedirectUri(provider);
|
||||||
|
const authUrl = buildAuthorizationUrl(provider, state, codeChallenge, redirectUri);
|
||||||
|
|
||||||
|
res.json({ authUrl, state });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth callback handler
|
||||||
|
* Provider redirects here with authorization code
|
||||||
|
*/
|
||||||
|
export async function handleOAuthCallback(req: Request, res: Response) {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const { code, state } = req.query;
|
||||||
|
|
||||||
|
if (!code || !state || typeof state !== "string") {
|
||||||
|
return res.status(400).send("Invalid callback parameters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate state
|
||||||
|
const stateData = oauthStates.get(state);
|
||||||
|
if (!stateData || stateData.provider !== provider) {
|
||||||
|
return res.status(400).send("Invalid or expired state");
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthStates.delete(state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exchange code for token
|
||||||
|
const tokenData = await exchangeCodeForToken(
|
||||||
|
provider,
|
||||||
|
code as string,
|
||||||
|
getRedirectUri(provider),
|
||||||
|
stateData.codeVerifier
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch user identity from provider
|
||||||
|
const identity = await fetchProviderIdentity(provider, tokenData.access_token);
|
||||||
|
|
||||||
|
// Find or create subject
|
||||||
|
const { data: existingSubject } = await supabase
|
||||||
|
.from("aethex_subjects")
|
||||||
|
.select("*")
|
||||||
|
.eq("supabase_user_id", stateData.userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
let subjectId: string;
|
||||||
|
|
||||||
|
if (!existingSubject) {
|
||||||
|
const { data: newSubject, error: createError } = await supabase
|
||||||
|
.from("aethex_subjects")
|
||||||
|
.insert({ supabase_user_id: stateData.userId })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (createError) throw createError;
|
||||||
|
subjectId = newSubject.id;
|
||||||
|
} else {
|
||||||
|
subjectId = existingSubject.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if identity already exists
|
||||||
|
const { data: existingIdentity } = await supabase
|
||||||
|
.from("aethex_subject_identities")
|
||||||
|
.select("*")
|
||||||
|
.eq("issuer", provider)
|
||||||
|
.eq("external_id", identity.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingIdentity && existingIdentity.subject_id !== subjectId) {
|
||||||
|
return res.status(409).send(
|
||||||
|
`This ${provider} account is already linked to another AeThex account.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingIdentity) {
|
||||||
|
// Create new identity link
|
||||||
|
await supabase
|
||||||
|
.from("aethex_subject_identities")
|
||||||
|
.insert({
|
||||||
|
subject_id: subjectId,
|
||||||
|
issuer: provider,
|
||||||
|
external_id: identity.id,
|
||||||
|
external_username: identity.username,
|
||||||
|
verified: true,
|
||||||
|
metadata: identity.metadata
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to success page
|
||||||
|
res.redirect(`${getAppBaseUrl()}/settings?oauth=success&provider=${provider}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OAuth callback error:", error);
|
||||||
|
res.redirect(`${getAppBaseUrl()}/settings?oauth=error&provider=${provider}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for access token
|
||||||
|
*/
|
||||||
|
async function exchangeCodeForToken(
|
||||||
|
provider: string,
|
||||||
|
code: string,
|
||||||
|
redirectUri: string,
|
||||||
|
codeVerifier?: string
|
||||||
|
): Promise<{ access_token: string; token_type: string }> {
|
||||||
|
const config = getProviderConfig(provider);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (codeVerifier && provider === "roblox") {
|
||||||
|
params.append("code_verifier", codeVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(config.tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/json"
|
||||||
|
},
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Token exchange failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user identity from provider
|
||||||
|
*/
|
||||||
|
async function fetchProviderIdentity(
|
||||||
|
provider: string,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<{ id: string; username: string; metadata: any }> {
|
||||||
|
const config = getProviderConfig(provider);
|
||||||
|
|
||||||
|
const response = await fetch(config.userInfoUrl, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${provider} user info`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Map provider-specific response to our format
|
||||||
|
switch (provider) {
|
||||||
|
case "discord":
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
username: `${data.username}#${data.discriminator}`,
|
||||||
|
metadata: {
|
||||||
|
avatar: data.avatar,
|
||||||
|
email: data.email,
|
||||||
|
verified: data.verified
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
case "roblox":
|
||||||
|
return {
|
||||||
|
id: data.sub,
|
||||||
|
username: data.preferred_username || data.name,
|
||||||
|
metadata: {
|
||||||
|
profile: data.profile,
|
||||||
|
picture: data.picture
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
case "github":
|
||||||
|
return {
|
||||||
|
id: String(data.id),
|
||||||
|
username: data.login,
|
||||||
|
metadata: {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
avatar_url: data.avatar_url,
|
||||||
|
html_url: data.html_url
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown provider: ${provider}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build OAuth authorization URL
|
||||||
|
*/
|
||||||
|
function buildAuthorizationUrl(
|
||||||
|
provider: string,
|
||||||
|
state: string,
|
||||||
|
codeChallenge: string,
|
||||||
|
redirectUri: string
|
||||||
|
): string {
|
||||||
|
const config = getProviderConfig(provider);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
state,
|
||||||
|
scope: config.scope
|
||||||
|
});
|
||||||
|
|
||||||
|
if (provider === "roblox") {
|
||||||
|
params.append("code_challenge", codeChallenge);
|
||||||
|
params.append("code_challenge_method", "S256");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${config.authUrl}?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider configuration
|
||||||
|
*/
|
||||||
|
function getProviderConfig(provider: string) {
|
||||||
|
const configs = {
|
||||||
|
discord: {
|
||||||
|
clientId: process.env.DISCORD_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
|
||||||
|
authUrl: "https://discord.com/api/oauth2/authorize",
|
||||||
|
tokenUrl: "https://discord.com/api/oauth2/token",
|
||||||
|
userInfoUrl: "https://discord.com/api/users/@me",
|
||||||
|
scope: "identify email"
|
||||||
|
},
|
||||||
|
roblox: {
|
||||||
|
clientId: process.env.ROBLOX_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.ROBLOX_CLIENT_SECRET!,
|
||||||
|
authUrl: "https://apis.roblox.com/oauth/v1/authorize",
|
||||||
|
tokenUrl: "https://apis.roblox.com/oauth/v1/token",
|
||||||
|
userInfoUrl: "https://apis.roblox.com/oauth/v1/userinfo",
|
||||||
|
scope: "openid profile"
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
|
authUrl: "https://github.com/login/oauth/authorize",
|
||||||
|
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||||
|
userInfoUrl: "https://api.github.com/user",
|
||||||
|
scope: "read:user user:email"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return configs[provider as keyof typeof configs];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OAuth redirect URI
|
||||||
|
*/
|
||||||
|
function getRedirectUri(provider: string): string {
|
||||||
|
const baseUrl = process.env.NODE_ENV === "production"
|
||||||
|
? "https://aethex.app"
|
||||||
|
: `http://localhost:${process.env.PORT || 5000}`;
|
||||||
|
|
||||||
|
return `${baseUrl}/api/oauth/callback/${provider}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app base URL
|
||||||
|
*/
|
||||||
|
function getAppBaseUrl(): string {
|
||||||
|
return process.env.NODE_ENV === "production"
|
||||||
|
? "https://aethex.app"
|
||||||
|
: `http://localhost:${process.env.PORT || 5000}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCE helpers
|
||||||
|
*/
|
||||||
|
function generateCodeVerifier(): string {
|
||||||
|
const array = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return base64UrlEncode(array);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(verifier);
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||||
|
return base64UrlEncode(new Uint8Array(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(array: Uint8Array): string {
|
||||||
|
return Buffer.from(array)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { loginSchema, signupSchema } from "../shared/schema.js";
|
||||||
import { supabase } from "./supabase.js";
|
import { supabase } from "./supabase.js";
|
||||||
import { getChatResponse } from "./openai.js";
|
import { getChatResponse } from "./openai.js";
|
||||||
import { capabilityGuard } from "./capability-guard.js";
|
import { capabilityGuard } from "./capability-guard.js";
|
||||||
|
import { startOAuthLinking, handleOAuthCallback } from "./oauth-handlers.js";
|
||||||
|
|
||||||
// Extend session type
|
// Extend session type
|
||||||
declare module 'express-session' {
|
declare module 'express-session' {
|
||||||
|
|
@ -44,6 +45,14 @@ export async function registerRoutes(
|
||||||
app.use("/api/os/entitlements/*", capabilityGuard);
|
app.use("/api/os/entitlements/*", capabilityGuard);
|
||||||
app.use("/api/os/link/*", capabilityGuard);
|
app.use("/api/os/link/*", capabilityGuard);
|
||||||
|
|
||||||
|
// ========== OAUTH ROUTES ==========
|
||||||
|
|
||||||
|
// Start OAuth linking flow (get authorization URL)
|
||||||
|
app.post("/api/oauth/link/:provider", requireAuth, startOAuthLinking);
|
||||||
|
|
||||||
|
// OAuth callback (provider redirects here with code)
|
||||||
|
app.get("/api/oauth/callback/:provider", handleOAuthCallback);
|
||||||
|
|
||||||
// ========== MODE MANAGEMENT ROUTES ==========
|
// ========== MODE MANAGEMENT ROUTES ==========
|
||||||
|
|
||||||
// Get user mode preference
|
// Get user mode preference
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue