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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions .agents/skills/constructive-oauth/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# Constructive OAuth

OAuth identity sign-in with cross-origin token exchange for Constructive platform.

## Features

| Feature | Status |
|---------|--------|
| GitHub OAuth | ✅ Ready |
| Google OAuth | ✅ Ready |
| Apple OAuth | ✅ Ready |
| Cross-origin token exchange | ✅ Ready |
| Multi-tenant support | ✅ Ready |

## Architecture

```
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Frontend │────▶│ Auth Server │────▶│ OAuth │
│ (SPA/App) │ │ (Express) │ │ Provider │
└─────────────┘ └──────────────────┘ └──────────────┘
│ │
│ signInCrossOrigin │ sign_in_identity (DB)
▼ ▼
┌─────────────┐ ┌──────────────────┐
│ API │◀────│ PostgreSQL │
│ Server │ │ (sessions) │
└─────────────┘ └──────────────────┘
```

## Same-Origin vs Cross-Origin

OAuth flow supports two credential modes depending on your deployment:

| Mode | When to Use | Credential |
|------|-------------|------------|
| **Cross-Origin** | Frontend and auth server on different domains | Bearer token (Authorization header) |
| **Same-Origin** | Frontend and auth server on same domain | Cookie (HttpOnly session) |

### Cross-Origin (Bearer Token)

Use when frontend (`app.example.com`) and auth server (`auth.example.com`) are on different origins:

1. OAuth callback returns a one-time `token` in URL
2. Frontend exchanges token via `signInCrossOrigin` mutation
3. Response contains `accessToken` for Bearer authentication
4. Store token in localStorage/sessionStorage
5. Include `Authorization: Bearer <token>` header on all API requests

**Pros:** Works across any origin, no CSRF concerns
**Cons:** Token management, must handle expiry/refresh

### Same-Origin (Cookie)

Use when frontend and auth server share the same origin or are on subdomains with shared cookies:

1. OAuth callback sets session cookie directly (HttpOnly)
2. No token exchange needed
3. Cookies sent automatically with `credentials: 'include'`
4. CSRF protection required (see `constructive-cookie-csrf`)

**Pros:** Simpler flow, automatic credential handling
**Cons:** Requires CSRF protection, same-origin constraints

**Note:** Cookie auth is partially implemented (see issue #749). Use cross-origin Bearer token flow for now.

---

## Quick Start (Cross-Origin)

### 1. Redirect to OAuth

```typescript
const authEndpoint = 'http://auth.localhost:3000';
const provider = 'github';
const callbackUrl = encodeURIComponent(window.location.origin + '/auth/callback');

localStorage.setItem('oauth_auth_endpoint', authEndpoint);
window.location.href = `${authEndpoint}/auth/${provider}?redirect_uri=${callbackUrl}`;
```

### 2. Handle Callback

```typescript
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const error = params.get('error');

if (error) {
console.error('OAuth failed:', error);
return;
}
```

### 3. Exchange Token

```typescript
const authEndpoint = localStorage.getItem('oauth_auth_endpoint');

const response = await fetch(`${authEndpoint}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation SignInCrossOrigin($input: SignInCrossOriginInput!) {
signInCrossOrigin(input: $input) {
result {
id
userId
accessToken
accessTokenExpiresAt
isVerified
totpEnabled
}
}
}
`,
variables: {
input: { token, credentialKind: 'bearer' }
}
})
});

const { accessToken, userId } = (await response.json()).data.signInCrossOrigin.result;
```

### 4. Use Access Token

```typescript
fetch('http://api.localhost:3000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ query: '{ currentUserId }' })
});
```

## Server Configuration

### Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `OAUTH_SECRET` | **Yes** | Secret for signing OAuth state (CSRF protection) |

**Required in all environments.** Server throws error if not configured.

```bash
# Generate a secure secret
openssl rand -base64 32

# Set in environment
export OAUTH_SECRET="your-generated-secret"
```

---

## Configure Identity Provider

### 1. Create OAuth App

**GitHub:** https://github.com/settings/developers
- Callback URL: `http://auth.localhost:3000/auth/github/callback`

**Google:** https://console.cloud.google.com/apis/credentials
- Redirect URI: `http://auth.localhost:3000/auth/google/callback`

### 2. Configure Database

```sql
-- Set client_id and enable
UPDATE "{schema}-auth-private".identity_providers
SET client_id = 'your-client-id', enabled = true
WHERE slug = 'github';

-- Set client secret
SELECT "{schema}-auth-private".rotate_identity_provider_secret(
'provider-uuid',
'your-client-secret'
);

-- Enable identity sign-in
UPDATE "{schema}-auth-private".app_settings_auth
SET allow_identity_sign_in = true,
allow_identity_sign_up = true;
```

## Query Available Providers

No authentication required:

```graphql
query {
identityProviders {
nodes {
slug
kind
displayName
enabled
}
}
}
```

## Multi-Tenant

Each tenant has its own auth endpoint and providers:

```typescript
const tenantAuthEndpoint = `http://auth-${tenantSubdomain}.localhost:3000`;
```

Find tenant endpoint:
```sql
SELECT dom.subdomain, dom.domain
FROM services_public.domains dom
JOIN metaschema_public.database d ON dom.database_id = d.id
WHERE dom.subdomain LIKE 'auth-%';
```

## References

- `references/troubleshooting.md` - Common issues and fixes

## Key Files

| File | Purpose |
|------|---------|
| `graphql/server/src/middleware/oauth.ts` | OAuth callback handling |
| `graphql/server/src/middleware/auth.ts` | Authentication middleware |
| `packages/oauth/src/index.ts` | OAuth provider configuration |

## Related

- Issue #735 - Server-Side Auth Implementation Plan
- `constructive-cookie-csrf` - Cookie auth and CSRF (partial)
129 changes: 129 additions & 0 deletions .agents/skills/constructive-oauth/references/troubleshooting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# OAuth Troubleshooting

## OAuth Callback Errors

### fetch failed

**Symptom:** `CALLBACK_FAILED` with message `fetch failed`

**Cause:** HTTP_PROXY/HTTPS_PROXY environment variables interfere with Node.js fetch.

**Fix:**
```bash
HTTP_PROXY="" HTTPS_PROXY="" NO_PROXY="*" pnpm start
```

### PROVIDER_NOT_CONFIGURED

**Symptom:** OAuth redirect fails immediately

**Check:**
```sql
SELECT slug, client_id, client_secret_id, enabled
FROM "{schema}-auth-private".identity_providers
WHERE slug = 'github';
```

**Requirements:**
- `client_id` set
- `client_secret_id` set (use `rotate_identity_provider_secret`)
- `enabled = true`

### IDENTITY_SIGN_IN_DISABLED

**Symptom:** OAuth succeeds but returns error

**Fix:**
```sql
UPDATE "{schema}-auth-private".app_settings_auth
SET allow_identity_sign_in = true,
allow_identity_sign_up = true;
```

### GitHub "redirect_uri not associated"

**Cause:** OAuth App callback URL mismatch

**Fix:** Update GitHub OAuth App callback URL to:
```
http://auth.localhost:3000/auth/github/callback
```

For tenants:
```
http://auth-{subdomain}.localhost:3000/auth/github/callback
```

## Token Exchange Errors

### Invalid token or token expired

**Causes:**
1. Token already used (one-time only)
2. Token expired (5 min TTL)
3. Wrong endpoint (must match issuer)
4. JWT claims not persisted (server issue)

**Debug:**
```sql
-- Check recent sessions
SELECT id, user_id, created_at
FROM "{schema}-auth-private".sessions
ORDER BY created_at DESC LIMIT 5;
```

### JWT Claims Not Persisted

**Cause:** `set_config(..., true)` loses settings with connection pooling.

**Fix in oauth.ts:**
```typescript
// WRONG
await pool.query(`SELECT set_config('jwt.claims.user_agent', $1, true)`, [ua]);

// CORRECT - use dedicated client with session-level config
const client = await pool.connect();
try {
await client.query(`SELECT set_config('jwt.claims.user_agent', $1, false)`, [ua]);
// use same client for sign_in_identity
} finally {
client.release();
}
```

## Database Queries

### Find Tenant Schema

```sql
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE '%auth-private';
```

### Find Tenant Auth Endpoint

```sql
SELECT d.name, dom.subdomain, dom.domain
FROM services_public.domains dom
JOIN metaschema_public.database d ON dom.database_id = d.id
WHERE dom.subdomain LIKE 'auth-%';
```

### Verify Provider Config

```sql
SELECT id, slug, client_id, client_secret_id, enabled
FROM "{schema}-auth-private".identity_providers;
```

## Server Logs

```bash
# Hub environment
pnpm log public-server

# Check log files
cat .local/logs/public-server.log | tail -100
```

Look for `[oauth]`, `[auth]`, or `[server]` prefixed messages.