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
statetokens 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:
{ "type": "register", "state": "<unique-uuid>", "provider": "google" }
{ "type": "unregister", "state": "<unique-uuid>" }
Gateway → Client:
{ "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:
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.
// 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:
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
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
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:
// 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:
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 [email protected]." 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)
- Create a Google Cloud project at console.cloud.google.com
- Enable the required API (e.g. YouTube Data API v3)
- Create OAuth credentials → Web application
- Add authorized redirect URI:
https://oauth-4e370c62.adom.cloud/callback - Copy client ID + secret to the service's
credentials.json - Add test users — OAuth consent screen → Test users → add Gmail addresses of anyone who needs access
- Submit for OAuth verification when ready (removes "unverified app" / test user restriction)
- Request quota increase if needed