Skip to content

Repository files navigation

mesh-router-auth

Internal-only HTTP service that auto-registers OIDC clients with the co-located Authelia instance. Lets hash-lock (and any other) sidecars bootstrap OIDC credentials from a drop-in compose file — no pre-provisioning, no shared secret to inject.

What it does

A sidecar POSTs to http://auth-registrar:9092/register. The registrar:

  1. Identifies the caller via PTR lookup of the source IP on the pcs network's embedded DNS (127.0.0.11). The result is the caller's Docker container_name, which the app store already constrains to equal the store ID. client_id is derived, not accepted.
  2. Recomputes the exact hostnames that app may use — <client_id>-<suffix> for each configured suffix, plus the bare suffixes if it is the root app (see below). Never from anything the caller sent.
  3. Registers the client with the configured backend (Dex gRPC CreateClient, or the legacy register-oidc-client.sh shell-out for Authelia). Both are idempotent.
  4. Returns {client_id, client_secret, issuer_url, redirect_uris} — plus internal_issuer_url when the deployment configures one (see Internal issuer).

API

Two ways to state the callbacks. Prefer callback_path — the registrar then owns the host set, which is the only way a caller can pick up hosts that are not a function of its own name (see The root app).

POST /register
Content-Type: application/json

{ "callback_path": "/nhl-auth/oidc/callback" }
200 OK
{
  "client_id": "myapp",
  "client_secret": "a1b2c3...",
  "issuer_url": "https://auth-alice.nsl.sh",
  "redirect_uris": [
    "https://myapp-alice.nsl.sh/nhl-auth/oidc/callback",
    "https://myapp-203-0-113-10.nip.io/nhl-auth/oidc/callback",
    "https://myapp-203-0-113-10.sslip.io/nhl-auth/oidc/callback"
  ]
}

The legacy form still works unchanged — send redirect_uris and each is verified against the same recomputed allowlist:

{ "redirect_uris": ["https://myapp-alice.nsl.sh/nhl-auth/oidc/callback"] }

redirect_uris is echoed in the response either way, so a caller can always adopt the authoritative list rather than recomputing it.

Sending both is legal, and callback_path wins. A caller that doesn't know whether it's talking to a registrar old enough to ignore callback_path sends both: the path for us, its own best guess for an older registrar. That combination must not error, and the path has to be the one that counts — the guess is exactly what gets the bare root hostname wrong.

GET /health → 200 {"status":"ok"}

Error codes:

  • 400 — malformed request, invalid redirect URI or callback_path, invalid PTR result
  • 403 — PTR lookup failed (caller not on the expected Docker network)
  • 500 — backend registration failed
  • 503 — the identity provider cannot be reached (Dex gRPC UNAVAILABLE, or no answer within 5 s). Not a fault: on a box with no login connector Dex is deliberately absent. Body {"error":"login_unavailable","setup_url":"…"} (setup_url only when SETUP_URL is set), plus Retry-After: 15. A gate shows this as "sign-in is not available yet", links to setup_url if there is one, and retries.

The root app

Every app is reachable at <app>-<suffix>. One app is also reachable at each suffix bare — wisera.inojob.com as well as maison-wisera.inojob.com — because Caddy's root-domain block reverse-proxies the bare hostname to it.

That host is not derivable from the app's own name, so an app computing its own callback list always misses it. The gate then has no registrable callback on the host the user actually arrived at, and has to bounce them to <app>-<suffix> mid-login — which users experience as "SSO moved me to a different URL".

ROOT_CLIENT_ID names the container that owns the bare hostnames. The deployment feeds it Caddy's own DEFAULT_SERVICE_HOST, so the app that owns the bare hostname and the app the bare hostname routes to are one fact, not two settings that can drift.

It is compared against the PTR-attested client_id, so an app still cannot claim the bare hostname by asking — it has to actually be the container the root domain points at. Bare hosts are appended to the list, never prepended: callers treat entry 0 as their canonical origin, and for an AppShield running as an OAuth AS that value is the issuer baked into already-issued tokens.

DEFAULT_SERVICE_HOST is a proxy upstream target, not an identity — the Caddyfile documents host.docker.internal as legal, and the settings-center Domain panel accepts dots and uppercase. Anything that isn't a valid container name is rejected at boot and nobody gets the bare hostnames. The resolution is logged on startup:

[registrar] root app (may claim the bare suffixes): maison
[registrar] root app (may claim the bare suffixes): none

Internal issuer

issuer_url is a public hostname, and it has to be: it is the iss of every token and the address the browser is redirected to. But a relying party also calls the issuer server-to-server — discovery, the code→token exchange, the JWKS fetch — and resolving that hostname from inside the box sends those calls out through the gateway/CDN and back in. That hairpin is slow, depends on external DNS and TLS for traffic between two containers on one bridge, and has been seen answering a majority of requests with an empty body.

INTERNAL_ISSUER_URL is the same issuer as reached from inside (on a PCS: http://dex:5556, the listener Caddy already proxies to). When set, the response carries it:

"issuer_url": "https://auth-alice.nsl.sh",
"internal_issuer_url": "http://dex:5556"

The contract for a caller: keep using issuer_url for everything that is identity or browser-facing, and send your own requests for URLs under issuer_url to the same path under internal_issuer_url instead. The two must be the same server — same paths, same keys — only the address differs. AppShield implements it (OIDC back-channel in its README).

It is the mirror image of the back-channel logout URI, which the registrar already derives as http://<client-id>/… so that Dex reaches the gate without leaving the box either.

Compatible both ways: a caller that predates the field ignores it and keeps dialling the public issuer; a caller that understands it, talking to a registrar that doesn't send it, does the same.

Security model

  • Never expose this service publicly. Internal pcs network only. Use expose: in compose, never ports:.
  • IP spoofing on a Docker bridge can't complete a TCP three-way handshake, so source-IP-derived identity is safe for this HTTP API.
  • container_name uniqueness is enforced by Docker — duplicates are rejected at container start.
  • Redirect-URI validation is the second line of defense: even with attestation, a compromised app must not be able to register a callback outside its own subdomain. The ValidationError tests (src/tests/validation.spec.ts) cover the typosquat cases (myapp2.*, myappX.*).

Configuration

Variable Required Default Description
ISSUER_URL yes — OIDC issuer URL (e.g. https://auth-${DOMAIN}). Returned to clients so they don't need to configure it.
INTERNAL_ISSUER_URL no — The same issuer as reached from inside the box (e.g. http://dex:5556). Returned as internal_issuer_url; a client that understands it sends its discovery, token and JWKS requests there and keeps issuer_url as the identity, so those calls never leave the machine. Unset ⇒ field omitted, clients dial the public issuer. See Internal issuer.
PORT no 9092 Listen port (pcs-internal).
REGISTRAR_BACKEND no authelia dex → Dex gRPC CreateClient; authelia → register-oidc-client.sh shell-out.
REDIRECT_HOST_SUFFIXES no — Comma-separated host suffixes (the part after <app>-), e.g. ${DOMAIN},${PUBLIC_IP_DASH}.nip.io,${PUBLIC_IP_DASH}.sslip.io. Unset ⇒ fail closed: every registration is rejected. Must list the same suffixes the apps' caddy_* labels use.
ROOT_CLIENT_ID no — Container the root domain proxies to; the one app that may also register callbacks on the bare suffixes. Fed from Caddy's DEFAULT_SERVICE_HOST. Non-container values are rejected at boot and nobody claims the bare hosts. See The root app.
SETUP_URL no — Absolute http(s) URL where the box gets set up (on a PCS: its onboarding page). Returned as setup_url in the 503 login_unavailable, so the gate can link to it. Invalid ⇒ fatal at boot. Unset ⇒ field omitted.
DEX_GRPC_ADDR no dex:5557 Dex gRPC endpoint (REGISTRAR_BACKEND=dex). Must be reachable only on an internal network — the API is unauthenticated.
DEX_CLIENTS_DIR no /DATA/AppData/yundera/dex/clients Where issued client secrets are persisted (Dex never echoes them back).
REGISTER_SCRIPT_PATH no /yundera/scripts/tools/register-oidc-client.sh Path to the script inside the container (REGISTRAR_BACKEND=authelia).
DNS_RESOLVER no 127.0.0.11 Docker embedded DNS. Override only for testing.

Deployment

Current PCS shape (Dex backend, from mesh-router-template-root's stacks/auth/docker-compose.yml):

auth-registrar:
  image: ghcr.io/yundera/mesh-auth:1.1.8
  container_name: auth-registrar          # PTR-attested; apps reach it by this name
  environment:
    ISSUER_URL: https://auth-${DOMAIN}
    REGISTRAR_BACKEND: dex
    INTERNAL_ISSUER_URL: http://dex:5556
    DEX_GRPC_ADDR: dex-grpc:5557          # alias on the isolated dex-internal net
    DEX_CLIENTS_DIR: /tmp/dex-clients
    # Must list the SAME suffixes the apps' caddy_* labels use.
    REDIRECT_HOST_SUFFIXES: "${DOMAIN},${PUBLIC_IP_DASH}.nip.io,${PUBLIC_IP_DASH}.sslip.io"
    # Caddy's root-domain upstream — the app that also owns the BARE suffixes.
    # Same variable that drives the Caddyfile's root block, deliberately: one
    # fact, so ownership and routing cannot disagree.
    ROOT_CLIENT_ID: "${DEFAULT_SERVICE_HOST}"
  expose:
    - "9092"
  networks:
    pcs: null           # /register from app sidecars + PTR attestation
    dex-internal: null  # gRPC to Dex
  # No depends_on: dex. Dex is absent on a box with no login connector, and the
  # registrar dials it lazily, answering 503 login_unavailable meanwhile.

The legacy Authelia backend (REGISTRAR_BACKEND=authelia) instead needs /DATA/AppData/casaos/apps/yundera:/yundera:ro, /DATA/AppData/yundera/auth, and the Docker socket (the script shells out to authelia/authelia for argon2 hashing).

Caller contract (hash-lock sidecar, etc.)

const res = await fetch("http://auth-registrar:9092/register", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    callback_path: "/nhl-auth/oidc/callback",
    // Only needed while older registrars are still in the field; they ignore
    // callback_path and require this. Newer ones ignore THIS. Drop once every
    // PCS is on >= 0.3.0.
    redirect_uris: ownGuessAtHosts.map((h) => `https://${h}/nhl-auth/oidc/callback`),
  }),
});
const { client_id, client_secret, issuer_url, redirect_uris } = await res.json();

// Use what came back, not what you guessed: redirect_uris is the authoritative
// set, and it is the only way to learn about hosts that are not a function of
// your own container name (the bare root domain).
const origins = new Set((redirect_uris ?? ownGuess).map((u) => new URL(u).origin));

Idempotent — the underlying script reprints the stored secret on re-registration. Callers don't need to persist the secret; they can re-fetch on every boot.

Limitations

  • Changing the root app leaves a stale bare-host callback on the old one. Redirect URIs are only synced when an app re-registers, so after DEFAULT_SERVICE_HOST moves from A to B, A's client still lists https://<bare-domain>/…. It is inert — A no longer requests that host, and a code delivered there now reaches B, which cannot exchange it without A's secret — but it is untidy. Clean it by having A re-register (a restart is enough), or prune bare-suffix URIs on any register where client_id != ROOT_CLIENT_ID.
  • register-oidc-client.sh currently does not update redirect URIs on re-registration with different URIs. If an app's hostname changes (rename, domain move), the existing client entry must be deleted first. Tracked for a future --force / update path in template-root.
  • This service has docker socket access (to invoke argon2 hashing inside the Authelia image). That's the concentration-of-risk point — keep the attack surface minimal (no extra endpoints, no public exposure).

Development

pnpm install
pnpm test          # mocha unit tests for validation + server
pnpm start         # tsc-watch, reload on change
pnpm build         # tsc → dist/

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages