skill
OAuth
Public UnreviewedUse when implementing OAuth authentication for any Adom service or feature — YouTube, GitHub, Slack, or any provider that uses OAuth 2.0.
{
"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
}