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