IT-DEV/saas/conduit
  • JavaScript 100%
Find a file
Darren a013065956 Block PCT home tenant at token broker level
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>
2026-04-11 11:29:43 -05:00
config perf: 15s consent timeout, filter hidden tenants, --skip-consented pre-check 2026-03-25 11:46:40 -05:00
docs chore(conduit): flatten docs/ directory structure 2026-04-03 12:21:32 -05:00
lib fix: add TTL to _useApi cache so Keeper API failures self-heal 2026-04-10 10:54:25 -05:00
reports docs: consent batch report 2026-03-25 — 87/124 succeeded 2026-03-25 12:19:51 -05:00
routes Block PCT home tenant at token broker level 2026-04-11 11:29:43 -05:00
test chore(conduit): flatten docs/ directory structure 2026-04-03 12:21:32 -05:00
.env.example chore: rename to conduit, add express dependency 2026-03-25 15:03:23 -05:00
.gitignore feat(broker): persist token cache to disk 2026-03-26 15:21:40 -05:00
package-lock.json chore: rename to conduit, add express dependency 2026-03-25 15:03:23 -05:00
package.json chore: rename to conduit, add express dependency 2026-03-25 15:03:23 -05:00
README.md docs: update README for conduit — add API docs, deployment, token broker 2026-03-25 15:25:37 -05:00
run.mjs perf: 15s consent timeout, filter hidden tenants, --skip-consented pre-check 2026-03-25 11:46:40 -05:00
server.mjs Block PCT home tenant at token broker level 2026-04-11 11:29:43 -05:00

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:

  1. 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.

  2. Match buttons by value attribute, not class. M365 reuses input.button_primary for both "Next" and "Accept" buttons. Use input[type="submit"][value="Accept"] not input.button_primary.

  3. 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. Use page.on('framenavigated') to capture it, not waitForURL.

  4. Handle the Pick Account page. Consent flows trigger prompt=select_account, which shows an account picker after MFA. Click the account tile using div.table >> text=email@domain.com, not .tile class (which includes "Use another account" and "Sign-in options").

  5. 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 login type uses fields[].type === "oneTimeCode", v2 general type uses flat totp property. Both are handled.
  • Browser pool contention — if more than MAX_BROWSER_SESSIONS concurrent 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