{
  "schema_version": 1,
  "type": "skill",
  "slug": "oauth",
  "title": "OAuth",
  "brief": "Use when implementing OAuth authentication for any Adom service or feature — YouTube, GitHub, Slack, or any provider that uses OAuth 2.0.",
  "version": "1.0.0",
  "tags": [],
  "license": "MIT",
  "source_path": "SKILL.md",
  "readme": "# Adom OAuth\n\nAdd 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.\n\n## Architecture\n\nEvery 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.\n\n```\nUser clicks \"Connect\" in their container\n  → Container connects to OAuth Gateway via WebSocket\n  → Container registers a unique state token\n  → User is redirected to OAuth provider (Google, GitHub, etc.)\n     with redirect_uri = https://<gateway-host>/callback\n  → User authorizes the app\n  → Provider redirects to Gateway: /callback?code=XXX&state=YYY\n  → Gateway looks up which WebSocket owns that state\n  → Sends the auth code back over WebSocket\n  → Container exchanges code for tokens locally\n  → Container disconnects from Gateway\n```\n\n**Key properties:**\n- One fixed redirect URI for all users, all services, all providers\n- Credentials and tokens never pass through the gateway — only the auth code\n- Gateway is stateless — just routes `state` tokens to WebSocket connections\n- Connections are short-lived (connect, get code, disconnect)\n\n## OAuth Gateway Service\n\n**Location:** `gallia/services/oauth-gateway/`\n**Port:** 8795\n**Container:** `john-service-oauth-4e370c6271ae0433`\n**Callback URL:** `https://oauth-4e370c62.adom.cloud/callback`\n\n### HTTP Endpoints\n\n| Method | Route | Description |\n|--------|-------|-------------|\n| GET | `/health` | Health check with pending count and uptime |\n| GET | `/callback` | OAuth callback — routes to the container that registered the state |\n| GET | `/status` | JSON status (pending count, uptime) |\n\n### WebSocket Protocol\n\nClients connect to the gateway via WebSocket and exchange JSON messages:\n\n**Client → Gateway:**\n```json\n{ \"type\": \"register\", \"state\": \"<unique-uuid>\", \"provider\": \"google\" }\n{ \"type\": \"unregister\", \"state\": \"<unique-uuid>\" }\n```\n\n**Gateway → Client:**\n```json\n{ \"type\": \"registered\", \"state\": \"<uuid>\" }\n{ \"type\": \"callback\", \"state\": \"<uuid>\", \"code\": \"<auth-code>\", \"query\": { ... } }\n{ \"type\": \"error\", \"state\": \"<uuid>\", \"error\": \"<message>\" }\n```\n\nRegistrations auto-expire after 10 minutes. When a WebSocket disconnects, all its registrations are cleaned up.\n\n## Client Library\n\n**Location:** `gallia/services/oauth-gateway/client.js`\n\n### Quick Start — `startOAuthFlow()`\n\nThe simplest way to add OAuth to any service:\n\n```javascript\nimport { startOAuthFlow } from '../services/oauth-gateway/client.js';\n\n// 1. Start the flow (connects to gateway, generates state, builds auth URL)\nconst flow = startOAuthFlow({\n  provider: 'google-youtube',\n  authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',\n  clientId: 'YOUR_CLIENT_ID.apps.googleusercontent.com',\n  scopes: 'https://www.googleapis.com/auth/youtube.upload',\n  extraParams: { access_type: 'offline', prompt: 'consent' },\n});\n\n// 2. Redirect the user to the auth URL\n// (in an HTTP handler: res.writeHead(302, { Location: flow.authRedirectUrl }))\n\n// 3. Wait for the gateway to deliver the auth code\nconst { code } = await flow.waitForCode();\n\n// 4. Exchange the code for tokens (using your app's client secret)\nconst tokens = await exchangeCodeForTokens(code, flow.redirectUri);\n```\n\n### Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `OAUTH_GATEWAY_URL` | `https://oauth-4e370c62.adom.cloud` | Gateway HTTP URL (for building redirect_uri) |\n| `OAUTH_GATEWAY_WS` | `wss://oauth-4e370c62.adom.cloud` | Gateway WebSocket URL |\n\nThese defaults are hardcoded in `client.js` — no env vars needed on user containers.\n\n## Adding OAuth to a New Service\n\n### Step 1: Bundle App Credentials\n\nStore 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.\n\n```json\n// your-service/credentials.json\n{\n  \"clientId\": \"YOUR_CLIENT_ID.apps.googleusercontent.com\",\n  \"clientSecret\": \"YOUR_CLIENT_SECRET\"\n}\n```\n\nRegister the gateway's callback URL as an authorized redirect URI in the provider's console:\n```\nhttps://<gateway-host>/callback\n```\n\n### Step 2: Per-User Token Storage\n\nEach user's tokens go in their home directory:\n\n```javascript\nimport { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';\nimport { dirname } from 'path';\nimport { homedir } from 'os';\n\nconst TOKEN_PATH = `${homedir()}/.config/your-service-tokens.json`;\n\nfunction loadTokens() {\n  if (!existsSync(TOKEN_PATH)) return null;\n  return JSON.parse(readFileSync(TOKEN_PATH, 'utf-8'));\n}\n\nfunction saveTokens(tokens) {\n  const dir = dirname(TOKEN_PATH);\n  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n  writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2) + '\\n');\n}\n```\n\n### Step 3: Add Auth Route to Your Server\n\n```javascript\nimport { startOAuthFlow } from '../services/oauth-gateway/client.js';\nimport credentials from './credentials.json' with { type: 'json' };\n\n// GET /your-service/auth — starts the OAuth flow\napp.get('/your-service/auth', (req, res) => {\n  const flow = startOAuthFlow({\n    provider: 'your-provider',\n    authUrl: 'https://provider.com/oauth/authorize',\n    clientId: credentials.clientId,\n    scopes: 'scope1 scope2',\n    extraParams: { access_type: 'offline' },\n  });\n\n  // Redirect user to provider\n  res.redirect(flow.authRedirectUrl);\n\n  // Wait for callback in background\n  flow.waitForCode().then(async ({ code }) => {\n    const tokenRes = await fetch('https://provider.com/oauth/token', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      body: new URLSearchParams({\n        code,\n        client_id: credentials.clientId,\n        client_secret: credentials.clientSecret,\n        redirect_uri: flow.redirectUri,\n        grant_type: 'authorization_code',\n      }),\n    });\n    const tokens = await tokenRes.json();\n    saveTokens(tokens);\n  }).catch(err => console.error('OAuth failed:', err.message));\n});\n```\n\n### Step 4: Token Refresh\n\n```javascript\nconst TOKEN_URL = 'https://provider.com/oauth/token';\nconst REFRESH_MARGIN_MS = 60_000;\n\nasync function getAccessToken() {\n  let tokens = loadTokens();\n  if (!tokens?.refreshToken) throw new Error('Not connected');\n\n  // Return cached if still valid\n  if (tokens.accessToken && tokens.expiresAt > Date.now() + REFRESH_MARGIN_MS) {\n    return tokens.accessToken;\n  }\n\n  // Refresh\n  const res = await fetch(TOKEN_URL, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n    body: new URLSearchParams({\n      client_id: credentials.clientId,\n      client_secret: credentials.clientSecret,\n      refresh_token: tokens.refreshToken,\n      grant_type: 'refresh_token',\n    }),\n  });\n  const newTokens = await res.json();\n  tokens.accessToken = newTokens.access_token;\n  tokens.expiresAt = Date.now() + (newTokens.expires_in * 1000);\n  if (newTokens.refresh_token) tokens.refreshToken = newTokens.refresh_token;\n  saveTokens(tokens);\n  return tokens.accessToken;\n}\n```\n\n### Step 5: UI — Connect Button\n\nIn your HTML, show a \"Connect\" button when the service isn't configured:\n\n```javascript\n// Check connection status\nconst res = await fetch('/your-service/status');\nconst { configured } = await res.json();\n\nif (!configured) {\n  // Show \"Connect\" button that opens /your-service/auth in a new tab\n  window.open('/your-service/auth', '_blank');\n  // Poll /your-service/status every 2s until configured\n}\n```\n\n## Remote Management via Container Conduit\n\nThe 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.\n\n**Container name:** `oauth-gateway`\n\n### Common Operations\n\n**Check if the gateway is running:**\n```\ncontainer_exec on oauth-gateway: curl -sf http://127.0.0.1:8795/health\n```\n\n**Check watchdog status:**\n```\ncontainer_exec on oauth-gateway: curl -sf http://127.0.0.1:8796/status\n```\n\n**Restart the gateway (via watchdog):**\n```\ncontainer_exec on oauth-gateway: curl -sf -X POST http://127.0.0.1:8796/restart\n```\n\n**Start/stop the gateway:**\n```\ncontainer_exec on oauth-gateway: curl -sf -X POST http://127.0.0.1:8796/start\ncontainer_exec on oauth-gateway: curl -sf -X POST http://127.0.0.1:8796/stop\n```\n\n**View recent logs:**\n```\ncontainer_exec on oauth-gateway: tail -50 /tmp/oauth-gateway.log\n```\n\n**Pull latest code from gallia and restart:**\n```\ncontainer_exec on oauth-gateway: cd /home/adom/gallia && git pull\ncontainer_exec on oauth-gateway: curl -sf -X POST http://127.0.0.1:8796/restart\n```\n\n**Full system status (disk, memory, uptime):**\n```\ncontainer_status on oauth-gateway\n```\n\n### Bootstrapping Container Conduit (if reinstall needed)\n\nIf the Conduit agent stops working, open a terminal on the OAuth container and run:\n```bash\ncurl -sL https://conduit-d4d7f7f2.adom.cloud/agent/install | CC_BASE_URL=https://conduit-f280e93f.adom.cloud CC_NAME=oauth-gateway sudo -E bash\n```\n\nThe agent auto-starts on reboot via cron. Config is at `/opt/container-conduit/config.json`.\n\n## Watchdog + Management Dashboard\n\nThe 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.\n\nThe watchdog also exposes a control API:\n\n| Method | Route | Description |\n|--------|-------|-------------|\n| GET | `/status` | Watchdog state, last health check, restart count |\n| POST | `/start` | Start the gateway |\n| POST | `/stop` | Stop the gateway |\n| POST | `/restart` | Restart the gateway |\n| POST | `/watchdog` | Toggle auto-restart on/off |\n\nA **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.\n\nBoth the gateway and watchdog are started by `start-oauth-gateway.sh`, which is called on container boot.\n\n## File Locations\n\n| Path | Description |\n|------|-------------|\n| `gallia/services/oauth-gateway/server.js` | Gateway server (port 8795) |\n| `gallia/services/oauth-gateway/watchdog.js` | Watchdog + control API (port 8796) |\n| `gallia/services/oauth-gateway/client.js` | Client library for services |\n| `gallia/services/oauth-gateway/dashboard.html` | Ops management dashboard (pushed to AV) |\n| `gallia/services/oauth-gateway/start-oauth-gateway.sh` | Starts gateway + watchdog (idempotent) |\n| `gallia/services/oauth-gateway/show-dashboard.sh` | Pushes dashboard to Adom Viewer |\n| `gallia/services/oauth-gateway/service.json` | Service manifest |\n| `gallia/youtube/credentials.json` | YouTube app credentials (bundled) |\n| `gallia/youtube/youtube-api.js` | YouTube token management + upload |\n| `~/.config/youtube-tokens.json` | Per-user YouTube tokens |\n\n## Supported Providers\n\nThe gateway is provider-agnostic. Any OAuth 2.0 provider works:\n\n| Provider | Auth URL | Scopes |\n|----------|----------|--------|\n| Google (YouTube) | `accounts.google.com/o/oauth2/v2/auth` | `youtube.upload` |\n| Google (Drive) | `accounts.google.com/o/oauth2/v2/auth` | `drive.file` |\n| GitHub | `github.com/login/oauth/authorize` | `repo`, `user` |\n| Slack | `slack.com/oauth/v2/authorize` | `chat:write`, etc. |\n\n## Test Users (IMPORTANT)\n\nUntil 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).\n\n**To add a test user:** Google Cloud Console → OAuth consent screen → Test users → Add users → enter their Gmail address.\n\n**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 [email protected].\" This saves users from a confusing dead end.\n\n**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.\n\n## Google Cloud Setup (for Adom admins)\n\n1. Create a Google Cloud project at console.cloud.google.com\n2. Enable the required API (e.g. YouTube Data API v3)\n3. Create OAuth credentials → Web application\n4. Add authorized redirect URI: `https://oauth-4e370c62.adom.cloud/callback`\n5. Copy client ID + secret to the service's `credentials.json`\n6. **Add test users** — OAuth consent screen → Test users → add Gmail addresses of anyone who needs access\n7. Submit for OAuth verification when ready (removes \"unverified app\" / test user restriction)\n8. Request quota increase if needed",
  "author": {
    "name": "Kyle Bergstedt",
    "email": "[email protected]"
  },
  "visibility": {
    "public": true
  },
  "hero": null,
  "sample_prompts": [],
  "discovery_triggers": [],
  "discovery_pitch": null,
  "metadata": {},
  "created_at": "2026-05-28T05:29:51.972Z",
  "updated_at": "2026-05-28T05:29:51.972Z",
  "sub_skills": [],
  "parent_app": null
}