- JavaScript 100%
Adds assertNotPCTTenant guard to Conduit's resolveTenant() and warmTenant() — blocks tenant 912570cc (PCT Support) before any token acquisition, refresh, or browser warm. Protects all downstream consumers (m365-tool, stack-dashboard, future apps) at the infrastructure layer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| config | ||
| docs | ||
| lib | ||
| reports | ||
| routes | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| run.mjs | ||
| server.mjs | ||
Conduit
M365 automation platform for partner-managed multi-tenant environments. Provides token broker, admin consent automation, and GDAP relationship management via CLI and HTTP API. Uses Playwright to script the full M365 login flow (username, password, TOTP MFA) with credentials from Keeper CLI.
Two Entry Points
CLI: node run.mjs
Command-line interface for scripting GDAP, consent, and customer operations.
Server: node server.mjs
Express HTTP API server with bearer token authentication, token broker, and async job queue.
CLI Commands
# GDAP management
node run.mjs gdap list # List all GDAP relationships
node run.mjs gdap list --status active # Filter by status
node run.mjs gdap list --customer "Contoso" # Filter by customer
node run.mjs gdap create --tenant <id> --name "PCT - Contoso" # Create + lock for approval
node run.mjs gdap approve --id <rel-id> # Approve pending relationship
node run.mjs gdap approve --all # Approve all pending
node run.mjs gdap terminate --id <rel-id> # Terminate (needs Admin Agent role)
# Admin consent
node run.mjs consent --app-id <id> --tenant "Contoso" # Grant consent in one tenant
node run.mjs consent --app-id <id> --all # Grant consent across all tenants
node run.mjs consent --app-id <id> --all --parallel 5 # 5 tenants at a time
# Customer list
node run.mjs customers # List all Partner Center customers
# Tenant-side operations (OAuth as customer GA)
node run.mjs tenant gdap-list --tenant "Contoso" # List GDAP from tenant side
node run.mjs tenant gdap-terminate --tenant "Contoso" --id <rel-id> # Terminate from tenant side
CLI Flags
| Flag | Default | Description |
|---|---|---|
--headless |
false | Run browser without visible window |
--dry-run |
false | Login but don't click approve/accept |
--timeout <ms> |
30000 | Per-step timeout |
--parallel <n> |
1 | Run N tenants simultaneously |
--keeper-uid <uid> |
— | Skip Keeper search, use this record directly |
--screenshot-dir <path> |
screenshots/ | Override screenshot output |
HTTP API
Startup
CONDUIT_API_TOKEN="your-secret-token" \
MAX_BROWSER_SESSIONS=3 \
PORT=3010 \
node server.mjs
Server will boot and warm the browser pool in parallel with Keeper shell initialization.
Authentication
All routes except /health require a Bearer token in the Authorization header:
curl -H "Authorization: Bearer $CONDUIT_API_TOKEN" \
https://conduit.pctbin.com/token?tenant=Contoso&service=graph
Routes
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
No | Health check + uptime |
| GET | /token?tenant=X&service=graph|exo|sharepoint |
Yes | Token broker (cache-first, warm-on-demand) |
| GET | /customers |
Yes | Partner Center customer list |
| GET | /gdap |
Yes | List all GDAP relationships |
| POST | /gdap |
Yes | Create and lock GDAP for approval (body: {tenantId, customerName, name}) |
| POST | /gdap/:id/approve |
Yes | Approve pending relationship (async job) |
| DELETE | /gdap/:id |
Yes | Terminate relationship (needs Partner Center Admin Agent role) |
| POST | /consent |
Yes | Grant admin consent (async job, body: {appId, tenant/all, parallel}) |
| GET | /jobs/:id |
Yes | Poll job status (returns {jobId, status, result, error}) |
Token Broker
The /token endpoint caches tokens in memory and refreshes on demand:
# Check cache (immediate response)
curl -H "Authorization: Bearer $TOKEN" \
"https://conduit.pctbin.com/token?tenant=Contoso&service=graph"
# Response (cache hit):
{
"accessToken": "eyJ0eXAi...",
"expiresIn": 3599,
"service": "graph",
"tenant": "contoso.com"
}
# Response (cache miss, warming):
{
"jobId": "job-12345",
"status": "warming",
"retryAfter": 30
}
If cache is stale but refreshable, token is refreshed synchronously. Otherwise, a background warm job is queued and jobId is returned with 202 status.
Async Jobs
Long-running operations (consent, GDAP approval, warm) return a job ID and poll-able status:
# 1. Initiate consent across 5 tenants in parallel
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"appId":"12345678-...", "all": true, "parallel": 5}' \
https://conduit.pctbin.com/consent
# Response:
{
"jobId": "job-consent-abc123",
"status": "queued"
}
# 2. Poll job status
curl -H "Authorization: Bearer $TOKEN" \
https://conduit.pctbin.com/jobs/job-consent-abc123
# Response (running):
{
"jobId": "job-consent-abc123",
"status": "running",
"progress": {
"completed": 2,
"total": 5
}
}
# Response (complete):
{
"jobId": "job-consent-abc123",
"status": "completed",
"result": {
"succeeded": ["contoso.com", "fabrikam.com", "adatum.com"],
"failed": [
{
"tenant": "wingtip.com",
"error": "AADSTS65004: User declined to consent to access the app."
}
]
}
}
Setup
npm install
npx playwright install chromium
cp .env.example .env # Fill in M365_CLIENT_ID, M365_REFRESH_TOKEN, M365_TENANT_ID, CONDUIT_API_TOKEN
Requires Keeper CLI installed and authenticated (keeper shell once to create device profile).
Environment Variables
| Variable | Required | Description |
|---|---|---|
M365_CLIENT_ID |
CLI + Server | Partner Center app client ID (GDAP creation, consent) |
M365_REFRESH_TOKEN |
CLI + Server | Partner Center refresh token |
M365_TENANT_ID |
CLI + Server | PCT tenant ID (Entra) |
CONDUIT_API_TOKEN |
Server | Bearer token for /token, /gdap, /consent, /customers, /jobs |
PORT |
Server | HTTP port (default 3010) |
MAX_BROWSER_SESSIONS |
Server | Concurrent browser instances (default 3) |
KEEPER_HOSTNAME |
Optional | Keeper server FQDN (default: cloud) |
Architecture
lib/
credentials.mjs — Keeper CLI wrapper (search, get, TOTP generation)
discovery.mjs — Partner Center customer list + Graph API GDAP discovery
auth-flow.mjs — M365 login state machine (7 states, selector-based transitions)
gdap.mjs — GDAP approval page automation
gdap-manage.mjs — GDAP lifecycle via Graph API (create, list, terminate)
consent.mjs — Admin consent page automation
tenant-graph.mjs — OAuth auth code flow as customer GA + Graph API helpers
graph-token.mjs — Shared Graph token acquisition (refresh token grant)
job-queue.mjs — In-memory job queue with dedup and poll-able status
keeper-shell.mjs — Persistent `keeper` shell with health checks
token-broker.mjs — Token cache with refresh/warm logic
logger.mjs — Structured JSONL logging + screenshot capture
routes/
middleware.mjs — Bearer token auth middleware
health.mjs — GET /health
token.mjs — GET /token?tenant&service (token broker)
customers.mjs — GET /customers (customer cache)
gdap.mjs — GET/POST/DELETE /gdap routes
consent.mjs — POST /consent (async job)
jobs.mjs — GET /jobs/:id
run.mjs — CLI entry point
server.mjs — Express server entry point
Playwright Selector Guidance
M365 login pages are React SPAs with dynamic DOM. Selectors change behavior across flow types (consent vs OAuth vs GDAP approval). When adding or debugging Playwright automation against M365:
-
Always DOM-dump first. Before writing selectors, run a headed debug session that logs all visible buttons, tiles, and inputs via
page.evaluate(). Never assume a selector from one flow works in another. -
Match buttons by
valueattribute, not class. M365 reusesinput.button_primaryfor both "Next" and "Accept" buttons. Useinput[type="submit"][value="Accept"]notinput.button_primary. -
Use navigation listeners for fleeting redirects. After clicking Accept on consent pages, the success URL (
admin_consent=True) appears for ~3 seconds before redirecting to/wrongplace. Usepage.on('framenavigated')to capture it, notwaitForURL. -
Handle the Pick Account page. Consent flows trigger
prompt=select_account, which shows an account picker after MFA. Click the account tile usingdiv.table >> text=email@domain.com, not.tileclass (which includes "Use another account" and "Sign-in options"). -
Expect MFA re-verification. After Pick Account, M365 may re-request TOTP even though MFA was just completed. The intermediate page handler must check for
input[name="otc"]in every loop iteration.
Deployment
Local Development
# Terminal 1: Start server
CONDUIT_API_TOKEN=dev node server.mjs
# Terminal 2: Run CLI commands against local fixtures
node run.mjs gdap list
Production (VMID 116)
# Copy to /opt/conduit/
cd /opt/conduit
npm install --production
# Start via systemd
sudo systemctl start conduit
sudo systemctl status conduit
sudo journalctl -u conduit -f
Service Configuration
Unit file: /etc/systemd/system/conduit.service
[Unit]
Description=Conduit M365 Automation Server
After=network.target
[Service]
Type=simple
User=conduit
WorkingDirectory=/opt/conduit
Environment="NODE_ENV=production"
Environment="CONDUIT_API_TOKEN=<from-vault>"
Environment="M365_CLIENT_ID=<from-vault>"
Environment="M365_REFRESH_TOKEN=<from-vault>"
Environment="M365_TENANT_ID=<from-vault>"
Environment="PORT=3010"
Environment="MAX_BROWSER_SESSIONS=3"
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
DNS and Reverse Proxy
Caddy config (/etc/caddy/Caddyfile):
conduit.pctbin.com {
reverse_proxy localhost:3010
encode gzip
}
Known Limitations
- GDAP termination from MSP side requires Admin Agent role in Partner Center (not available via current refresh token principal)
- AADSTS65004 errors on some tenants — consent blocked by tenant policy. Investigate per-tenant: could be admin consent workflow enabled or delegated admin restrictions
- Keeper record format varies — v3
logintype usesfields[].type === "oneTimeCode", v2generaltype uses flattotpproperty. Both are handled. - Browser pool contention — if more than
MAX_BROWSER_SESSIONSconcurrent jobs are queued, subsequent requests will wait. Monitor job queue depth via/health - Token cache TTL — tokens are cached in memory and lost on server restart. No persistence layer