Authentication

Gatehouse supports three authentication methods. They all produce (or are) a bearer token that goes in the Authorization header.

Root token#

Set via the GATEHOUSE_ROOT_TOKEN environment variable on the container. The root token has admin capability on everything. Use it to bootstrap the first admin user, then unset the env var and restart.

curl -H "Authorization: Bearer $GATEHOUSE_ROOT_TOKEN" \
  http://localhost:3100/v1/auth/users

User accounts (for humans)#

Admin users log into the web UI with a username and password. User accounts can optionally enable TOTP two-factor auth.

Login:

curl -X POST http://localhost:3100/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "password": "..."}'

Response (when TOTP is disabled):

{
  "token": "<JWT>",
  "identity": "user:alice",
  "expires_in": 86400
}

When TOTP is enabled, the login endpoint returns a short-lived pre-auth token that can only be exchanged for a full JWT at POST /v1/auth/login/totp with a valid 6-digit code or recovery code.

AppRoles (for agents)#

AppRoles are how agents authenticate. An admin creates an AppRole in the web UI or via the API, which produces a role_id and secret_id. The agent exchanges these for a JWT and uses the JWT for all subsequent requests.

Admin creates an AppRole:

curl -X POST http://localhost:3100/v1/auth/approle \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{"display_name": "my-agent", "policies": ["agent-readonly"]}'

Response:

{
  "role_id": "role-xxxx-...",
  "secret_id": "yyyy-...",
  "display_name": "my-agent",
  "policies": ["agent-readonly"],
  "warning": "Save the secret_id now, it cannot be retrieved later"
}

Agent exchanges credentials for a JWT:

curl -X POST http://localhost:3100/v1/auth/approle/login \
  -H "Content-Type: application/json" \
  -d '{"role_id": "role-xxxx-...", "secret_id": "yyyy-..."}'

Response:

{
  "token": "<JWT>",
  "identity": "approle:my-agent",
  "policies": ["agent-readonly"],
  "expires_in": 86400
}

Pasting role_id / secret_id into a chat window puts them into conversation history, logs, and anywhere the transcript gets shipped. Onboarding links solve that. The operator generates a one-time URL, hands it to the agent over any channel, and the agent fetches credentials directly.

Operator side: generate the link. In the web UI, open the AppRoles tab, click Onboard on the target row, pick a TTL (5 min to 1 hour), and copy the generated prompt. It’s a short curl snippet that writes the bootstrap markdown to a temp file and cats it back.

Or via the API:

curl -X POST http://localhost:3100/v1/onboard \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{"role_id": "role-xxxx-...", "ttl_seconds": 900, "label": "telegram bot"}'

Response:

{
  "id": "onboard-...",
  "onboard_url": "https://gatehouse.example/v1/onboard/<token>",
  "expires_at": "..."
}

Agent side: exchange the token. The agent runs the curl snippet, reads the markdown (which is self-installing instructions), and calls exchange exactly once:

curl -X POST https://gatehouse.example/v1/onboard/<token>/exchange

Response (consumed on success; subsequent calls return 410 Gone):

{
  "token": "<JWT>",
  "role_id": "role-...",
  "secret_id": "...",
  "base_url": "https://gatehouse.example",
  "mcp_url": "https://gatehouse.example/v1/mcp",
  "role_display_name": "my-agent",
  "policies": ["agent-readonly"],
  "expires_in": 86400
}

The agent writes role_id / secret_id into its harness’s credential path (~/.hermes/.env, ~/.claude/.env.gatehouse, ~/.openclaw/workspace/.env, or .env.gatehouse in cwd for Codex/Cursor/Windsurf) and installs a gatehouse skill so future sessions know how to use the vault.

Important behaviors:

  • Exchange rotates the AppRole’s secret_id. Any agent still running with the previous secret must re-onboard.
  • Links are single-use. After exchange they return 410 Gone.
  • Before exchange, the bootstrap markdown URL is idempotent: if the agent’s context gets compacted or the install fails mid-way, it can re-fetch the same URL until exchange is called.
  • If the AppRole has zero policies, the rendered markdown tells the agent to halt rather than probe, because gatehouse_list will be empty and every proxy call will be denied.
  • Set GATEHOUSE_PUBLIC_URL when behind a reverse proxy so the generated link uses the public hostname instead of the LAN address from the Host header.

Admin endpoints:

# List active links
curl http://localhost:3100/v1/onboard -H "Authorization: Bearer $ADMIN_JWT"

# Revoke an unused link
curl -X DELETE http://localhost:3100/v1/onboard/<id> \
  -H "Authorization: Bearer $ADMIN_JWT"

SSO (OpenID Connect)#

Gatehouse supports OIDC single sign-on for the web UI. The login page shows a Sign in with SSO button when SSO is configured; the username and token tabs continue to work for non-SSO users. Tested against PocketID, Authentik, Keycloak, and Google.

Account model: link-by-verified-email. SSO is not a way to create new accounts. The IdP-asserted email must match an existing Gatehouse user’s email field, and the user must be enabled = 1. Admins pre-create the user (Users tab) and populate email; the next time that person clicks Sign in with SSO, they land in their existing Gatehouse account.

Configure#

  1. Sign in with the bootstrap root token or an admin user.
  2. Go to Settings → SSO / OAuth Configuration and toggle Enable SSO on.
  3. Fill in:
    • Issuer URL: the IdP’s base URL (e.g. https://id.example.com). Gatehouse fetches <issuer>/.well-known/openid-configuration when you save and refuses the save if discovery fails.
    • Client ID / Client Secret: from your IdP’s OIDC client registration.
    • Redirect URI: https://<your-gatehouse-host>/v1/auth/sso/callback. Configure the same string in the IdP’s allowed redirect URIs.
    • Scopes: defaults to openid profile email. The openid scope is required.
  4. Save. Use the Test SSO link to dry-run the IdP redirect in a new tab without logging out.
  5. On each user record (Users tab), populate Email to match what the IdP will assert.

email_verified enforcement#

By default, Gatehouse requires the IdP’s userinfo to include email_verified: true. This is the safety net against an IdP that lets users self-attest emails.

Some IdPs (notably PocketID) intentionally omit the email_verified claim because their threat model is “the admin invited you, the email is administrator-controlled.” For those, enable Trust email without email_verified claim in the SSO settings card. The hint text on the toggle spells out the tradeoff. Default-safe; only flip it when you trust your IdP’s email setup.

Bootstrap via environment variables (optional)#

If you’d rather automate first-boot setup, set these on the container:

- GATEHOUSE_OAUTH_ISSUER=https://id.example.com
- GATEHOUSE_OAUTH_CLIENT_ID=gatehouse
- GATEHOUSE_OAUTH_CLIENT_SECRET=...
- GATEHOUSE_OAUTH_REDIRECT_URI=https://gatehouse.example.com/v1/auth/sso/callback

If no settings/sso row exists at boot, Gatehouse seeds one with enabled: true and these values. After boot, the database is the single source of truth and the env vars are ignored - manage SSO from the UI.

Endpoints#

MethodPathDescription
GET/v1/auth/sso/statusPublic. Returns {enabled: boolean}. Used by the unauth login page.
GET/v1/auth/sso/startPublic. Mints state + nonce + PKCE verifier, 302s to the IdP authorize URL.
GET/v1/auth/sso/callbackPublic. Verifies signature/iss/aud/exp/nonce, cross-checks userinfo sub (OIDC §5.3.2), links by email, mints a Gatehouse JWT, 302s to /#sso=<jwt> so the JWT lands in the URL fragment (not query string, not access logs).

Audit events#

Each callback path emits exactly one audit event. Sift /v1/audit?action=... or container stdout to debug:

EventWhen
sso.start/start route mints a state row and redirects.
sso.callback.successAll checks passed; JWT minted.
sso.callback.invalid_stateState row missing, expired (>10 min), or already consumed.
sso.callback.exchange_failedCode exchange failed, ID token verification failed, JWKS signature failed, or userinfo fetch failed. The audit row’s metadata.error carries the upstream error string.
sso.callback.unverified_emailIdP’s email_verified is not true and trust_unverified_email is off.
sso.callback.no_user_matchNo enabled Gatehouse user has the IdP-asserted email.
sso.callback.email_collisionTwo or more Gatehouse users share the IdP-asserted email (admin error).

The user-visible error page is intentionally vague (“Sign-in failed”, “Login link expired”, “No Gatehouse account is linked to this identity”). The audit log is the source of truth for which path actually fired.

TOTP (two-factor auth)#

Enable TOTP for a user account from the web UI: Me → Security → Enable 2FA. Scan the QR code with any RFC 6238 authenticator (Google Authenticator, Authy, 1Password, Aegis). Confirm the setup with a 6-digit code. You’ll be shown 10 one-time recovery codes. Save them somewhere safe.

After enabling TOTP:

  • Every subsequent login requires the 6-digit code after the password.
  • If you lose your authenticator, you can log in with one of the recovery codes (each consumable exactly once).
  • Admins can force-reset a user’s 2FA from the Users tab.