# Adom OAuth

Add OAuth 2.0 authentication to any Adom service using the central OAuth Gateway. Users click one button, authorize in their browser, and they're connected. No per-user setup, no CLI commands.

## Architecture

Every Adom user container has a unique hostname. OAuth providers (Google, GitHub, etc.) require a fixed `redirect_uri`. The **OAuth Gateway** solves this by providing a single static callback URL that routes auth codes to the correct container via WebSocket.

```
User clicks "Connect" in their container
  → Container connects to OAuth Gateway via WebSocket
  → Container registers a unique state token
  → User is redirected to OAuth provider (Google, GitHub, etc.)
     with redirect_uri = https://<gateway-host>/callback
  → User authorizes the app
  → Provider redirects to Gateway: /callback?code=XXX&state=YYY
  → Gateway looks up which WebSocket owns that state
  → Sends the auth code back over WebSocket
  → Container exchanges code for tokens locally
  → Container disconnects from Gateway
```

**Key properties:**
- One fixed redirect URI for all users, all services, all providers
- Credentials and tokens never pass through the gateway — only the auth code
- Gateway is stateless — just routes `state` tokens to WebSocket connections
- Connections are short-lived (connect, get code, disconnect)

## OAuth Gateway Service

**Location:** `gallia/services/oauth-gateway/`
**Port:** 8795
**Container:** `john-service-oauth-4e370c6271ae0433`
**Callback URL:** `https://oauth-4e370c62.adom.cloud/callback`

### HTTP Endpoints

| Method | Route | Description |
|--------|-------|-------------|
| GET | `/health` | Health check with pending count and uptime |
| GET | `/callback` | OAuth callback — routes to the container that registered the state |
| GET | `/status` | JSON status (pending count, uptime) |

### WebSocket Protocol

Clients connect to the gateway via WebSocket and exchange JSON messages:

**Client → Gateway:**
```json
{ "type": "register", "state": "<unique-uuid>", "provider": "google" }
{ "type": "unregister", "state": "<unique-uuid>" }
```

**Gateway → Client:**
```json
{ "type": "registered", "state": "<uuid>" }
{ "type": "callback", "state": "<uuid>", "code": "<auth-code>", "query": { ... } }
{ "type": "error", "state": "<uuid>", "error": "<message>" }
```

Registrations auto-expire after 10 minutes. When a WebSocket disconnects, all its registrations are cleaned up.

## Client Library

**Location:** `gallia/services/oauth-gateway/client.js`

### Quick Start — `startOAuthFlow()`

The simplest way to add OAuth to any service:

```javascript
import { startOAuthFlow } from '../services/oauth-gateway/client.js';

// 1. Start the flow (connects to gateway, generates state, builds auth URL)
const flow = startOAuthFlow({
  provider: 'google-youtube',
  authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
  clientId: 'YOUR_CLIENT_ID.apps.googleusercontent.com',
  scopes: 'https://www.googleapis.com/auth/youtube.upload',
  extraParams: { access_type: 'offline', prompt: 'consent' },
});

// 2. Redirect the user to the auth URL
// (in an HTTP handler: res.writeHead(302, { Location: flow.authRedirectUrl }))

// 3. Wait for the gateway to deliver the auth code
const { code } = await flow.waitForCode();

// 4. Exchange the code for tokens (using your app's client secret)
const tokens = await exchangeCodeForTokens(code, flow.redirectUri);
```

### Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `OAUTH_GATEWAY_URL` | `https://oauth-4e370c62.adom.cloud` | Gateway HTTP URL (for building redirect_uri) |
| `OAUTH_GATEWAY_WS` | `wss://oauth-4e370c62.adom.cloud` | Gateway WebSocket URL |

These defaults are hardcoded in `client.js` — no env vars needed on user containers.

## Adding OAuth to a New Service

### Step 1: Bundle App Credentials

Store your OAuth client ID and secret in a JSON file in your service directory. This ships with the gallia repo — all users get the same app credentials.

```json
// your-service/credentials.json
{
  "clientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
  "clientSecret": "YOUR_CLIENT_SECRET"
}
```

Register the gateway's callback URL as an authorized redirect URI in the provider's console:
```
https://<gateway-host>/callback
```

### Step 2: Per-User Token Storage

Each user's tokens go in their home directory:

```javascript
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import { homedir } from 'os';

const TOKEN_PATH = `${homedir()}/.config/your-service-tokens.json`;

function loadTokens() {
  if (!existsSync(TOKEN_PATH)) return null;
  return JSON.parse(readFileSync(TOKEN_PATH, 'utf-8'));
}

function saveTokens(tokens) {
  const dir = dirname(TOKEN_PATH);
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2) + '\n');
}
```

### Step 3: Add Auth Route to Your Server

```javascript
import { startOAuthFlow } from '../services/oauth-gateway/client.js';
import credentials from './credentials.json' with { type: 'json' };

// GET /your-service/auth — starts the OAuth flow
app.get('/your-service/auth', (req, res) => {
  const flow = startOAuthFlow({
    provider: 'your-provider',
    authUrl: 'https://provider.com/oauth/authorize',
    clientId: credentials.clientId,
    scopes: 'scope1 scope2',
    extraParams: { access_type: 'offline' },
  });

  // Redirect user to provider
  res.redirect(flow.authRedirectUrl);

  // Wait for callback in background
  flow.waitForCode().then(async ({ code }) => {
    const tokenRes = await fetch('https://provider.com/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        code,
        client_id: credentials.clientId,
        client_secret: credentials.clientSecret,
        redirect_uri: flow.redirectUri,
        grant_type: 'authorization_code',
      }),
    });
    const tokens = await tokenRes.json();
    saveTokens(tokens);
  }).catch(err => console.error('OAuth failed:', err.message));
});
```

### Step 4: Token Refresh

```javascript
const TOKEN_URL = 'https://provider.com/oauth/token';
const REFRESH_MARGIN_MS = 60_000;

async function getAccessToken() {
  let tokens = loadTokens();
  if (!tokens?.refreshToken) throw new Error('Not connected');

  // Return cached if still valid
  if (tokens.accessToken && tokens.expiresAt > Date.now() + REFRESH_MARGIN_MS) {
    return tokens.accessToken;
  }

  // Refresh
  const res = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: credentials.clientId,
      client_secret: credentials.clientSecret,
      refresh_token: tokens.refreshToken,
      grant_type: 'refresh_token',
    }),
  });
  const newTokens = await res.json();
  tokens.accessToken = newTokens.access_token;
  tokens.expiresAt = Date.now() + (newTokens.expires_in * 1000);
  if (newTokens.refresh_token) tokens.refreshToken = newTokens.refresh_token;
  saveTokens(tokens);
  return tokens.accessToken;
}
```

### Step 5: UI — Connect Button

In your HTML, show a "Connect" button when the service isn't configured:

```javascript
// Check connection status
const res = await fetch('/your-service/status');
const { configured } = await res.json();

if (!configured) {
  // Show "Connect" button that opens /your-service/auth in a new tab
  window.open('/your-service/auth', '_blank');
  // Poll /your-service/status every 2s until configured
}
```

## Remote Management via Container Conduit

The OAuth gateway container has Container Conduit installed, so you can manage it remotely from your main gallia editor without opening a separate VS Code session.

**Container name:** `oauth-gateway`

### Common Operations

**Check if the gateway is running:**
```
container_exec on oauth-gateway: curl -sf http://127.0.0.1:8795/health
```

**Check watchdog status:**
```
container_exec on oauth-gateway: curl -sf http://127.0.0.1:8796/status
```

**Restart the gateway (via watchdog):**
```
container_exec on oauth-gateway: curl -sf -X POST http://127.0.0.1:8796/restart
```

**Start/stop the gateway:**
```
container_exec on oauth-gateway: curl -sf -X POST http://127.0.0.1:8796/start
container_exec on oauth-gateway: curl -sf -X POST http://127.0.0.1:8796/stop
```

**View recent logs:**
```
container_exec on oauth-gateway: tail -50 /tmp/oauth-gateway.log
```

**Pull latest code from gallia and restart:**
```
container_exec on oauth-gateway: cd /home/adom/gallia && git pull
container_exec on oauth-gateway: curl -sf -X POST http://127.0.0.1:8796/restart
```

**Full system status (disk, memory, uptime):**
```
container_status on oauth-gateway
```

### Bootstrapping Container Conduit (if reinstall needed)

If the Conduit agent stops working, open a terminal on the OAuth container and run:
```bash
curl -sL https://conduit-d4d7f7f2.adom.cloud/agent/install | CC_BASE_URL=https://conduit-f280e93f.adom.cloud CC_NAME=oauth-gateway sudo -E bash
```

The agent auto-starts on reboot via cron. Config is at `/opt/container-conduit/config.json`.

## Watchdog + Management Dashboard

The service container runs a **watchdog** (`watchdog.js`, port 8796) alongside the gateway. It polls `/health` every 10 seconds and auto-restarts the gateway after 3 consecutive failures.

The watchdog also exposes a control API:

| Method | Route | Description |
|--------|-------|-------------|
| GET | `/status` | Watchdog state, last health check, restart count |
| POST | `/start` | Start the gateway |
| POST | `/stop` | Stop the gateway |
| POST | `/restart` | Restart the gateway |
| POST | `/watchdog` | Toggle auto-restart on/off |

A **management dashboard** (`dashboard.html`) can be pushed to the Adom Viewer on the service container via `show-dashboard.sh`. This is an ops interface — start/stop/restart buttons, watchdog toggle, and a state-change event log. It's separate from the read-only service dashboard that end users see in AV's dropdown menu.

Both the gateway and watchdog are started by `start-oauth-gateway.sh`, which is called on container boot.

## File Locations

| Path | Description |
|------|-------------|
| `gallia/services/oauth-gateway/server.js` | Gateway server (port 8795) |
| `gallia/services/oauth-gateway/watchdog.js` | Watchdog + control API (port 8796) |
| `gallia/services/oauth-gateway/client.js` | Client library for services |
| `gallia/services/oauth-gateway/dashboard.html` | Ops management dashboard (pushed to AV) |
| `gallia/services/oauth-gateway/start-oauth-gateway.sh` | Starts gateway + watchdog (idempotent) |
| `gallia/services/oauth-gateway/show-dashboard.sh` | Pushes dashboard to Adom Viewer |
| `gallia/services/oauth-gateway/service.json` | Service manifest |
| `gallia/youtube/credentials.json` | YouTube app credentials (bundled) |
| `gallia/youtube/youtube-api.js` | YouTube token management + upload |
| `~/.config/youtube-tokens.json` | Per-user YouTube tokens |

## Supported Providers

The gateway is provider-agnostic. Any OAuth 2.0 provider works:

| Provider | Auth URL | Scopes |
|----------|----------|--------|
| Google (YouTube) | `accounts.google.com/o/oauth2/v2/auth` | `youtube.upload` |
| Google (Drive) | `accounts.google.com/o/oauth2/v2/auth` | `drive.file` |
| GitHub | `github.com/login/oauth/authorize` | `repo`, `user` |
| Slack | `slack.com/oauth/v2/authorize` | `chat:write`, etc. |

## Test Users (IMPORTANT)

Until the app passes Google's OAuth verification, only **test users** explicitly added in Google Cloud Console can authorize. Anyone not on the list gets a hard block (not just a warning).

**To add a test user:** Google Cloud Console → OAuth consent screen → Test users → Add users → enter their Gmail address.

**When a user hits this error:** The YouTube upload dialog in Movie Maker should detect the `403 access_denied` error and show a message like: "YouTube access is restricted. Ask an Adom admin to add your Google account as a test user, or email john@adom.inc." This saves users from a confusing dead end.

**To remove the restriction permanently:** Submit the app for OAuth verification (Google Cloud Console → OAuth consent screen → Publish app). Google reviews the app and removes the test user gate. This requires a privacy policy URL, homepage, and possibly a demo video.

## Google Cloud Setup (for Adom admins)

1. Create a Google Cloud project at console.cloud.google.com
2. Enable the required API (e.g. YouTube Data API v3)
3. Create OAuth credentials → Web application
4. Add authorized redirect URI: `https://oauth-4e370c62.adom.cloud/callback`
5. Copy client ID + secret to the service's `credentials.json`
6. **Add test users** — OAuth consent screen → Test users → add Gmail addresses of anyone who needs access
7. Submit for OAuth verification when ready (removes "unverified app" / test user restriction)
8. Request quota increase if needed
