Skip to content

feat(client): let the CLI authenticate against a gateway in front of n8n - #51

Merged
syucream merged 3 commits into
mainfrom
feat/client-outbound-auth
Jul 30, 2026
Merged

feat(client): let the CLI authenticate against a gateway in front of n8n#51
syucream merged 3 commits into
mainfrom
feat/client-outbound-auth

Conversation

@syucream

Copy link
Copy Markdown
Contributor

Problem

Client.doRequest sends a fixed set of headers — X-N8N-API-KEY, Content-Type, Accept. There is no seam to attach anything else, so pointing N8N_API_URL at a gateway that authenticates callers per request fails at the edge:

$ N8N_API_URL=<gateway> N8N_API_KEY=dummy n8n-cli workflow list --limit 2
Error: Unexpected error (status 403)

The credential never reaches the gateway, and n8n is never involved. That leaves two bad options: bypass the gateway, or run a local relay process whose only job is to add two headers. Both defeat the purpose of deploying the gateway.

Changes

  • Client takes an egress middleware chain, wired from N8N_CLIENT_MIDDLEWARES. These are the same ClientMiddlewares the proxy subcommand already runs on its own egress, so there is one implementation and one config vocabulary for both directions. Empty by default — the direct-to-n8n path is byte-identical to before.
  • New iap-auth token source adc-impersonate: mints the id_token as a target service account using local Application Default Credentials (ADC refresh-token grant → iamcredentials.generateIdToken). The metadata source only covers workloads; a developer machine previously had no way to produce that token except pasting gcloud auth print-identity-token --impersonate-service-account=... into an env var every hour.
  • Audience derivation instead of a required constant. impersonator-token's audience is now optional: the ADC source names its own, because the refresh-token grant only issues tokens for the client that owns the credentials — asking for a different project's client fails outright with invalid_audience. A configured constant that doesn't match the credentials in use fails at the far end with nothing but "verification failed", which is a miserable thing to debug. iap-auth likewise defaults its audience to the API URL, which is what a Cloud Run gateway expects.
  • N8N_API_KEY is optional when an egress chain is configured. A gateway that terminates authentication holds the n8n key and injects it upstream, so the caller has none; demanding one anyway pushes operators to park a placeholder — or the real shared key — in every developer's shell.
  • Accept-Encoding: identity on API requests. A proxy commonly re-emits the upstream's Content-Encoding over a body its own HTTP client already decoded, and that mismatch arrives here as an unrecoverable decompression failure. Uncompressed transfers cost bandwidth a CLI can afford; a read path that dies on large responses is not affordable.

Resulting setup — no relay process, no manual token step:

export N8N_API_URL="https://gateway.example.com"
export N8N_CLIENT_MIDDLEWARES="iap-auth,impersonator-token"
export N8N_IAP_AUTH_TOKEN_SOURCE="adc-impersonate"
export N8N_IAP_AUTH_IMPERSONATE_SERVICE_ACCOUNT="gate-caller@example.iam.gserviceaccount.com"
export N8N_IMPERSONATOR_TOKEN_SOURCE="adc"

Verification

Every check below ran the built CLI against a deployed gateway fronting a real n8n — no curl, no relay process, no hand-minted tokens, and no N8N_API_KEY:

Command Result
workflow list --limit 3 3 workflows returned
workflow get <id> returned, fields intact
apply --ids=<id> --dry-run 1 unchanged
workflow create -f <lint-violating>.json --no-lint refused by the gateway: Workflow violates 2 linter rules and was not forwarded to n8n
workflow create -f <clean>.json created
workflow update <id> -f <edited>.json updated; workflow get shows the edit
workflow delete <id> --force deleted; a 100-row list confirms no residue

Notably the read path succeeded against a gateway whose own build predates the Content-Encoding fix in #50 — the identity request header sidesteps it, so clients are not blocked on the gateway being rebuilt.

Unit tests: tests/api/client-egress.test.ts (chain applied to every call, absent chain leaves requests untouched, a throwing middleware aborts rather than sending unauthenticated, identity encoding requested) and tests/middleware/builtin/iap-auth/adc-impersonate-source.test.ts (grant → mint sequence and payloads, per-audience caching, and the error messages for a key file, an incomplete ADC file, and a denied impersonation).

Suite: 1036 pass, 1 fail — tests/lint/rules/no-plaintext-secrets.test.ts also fails on origin/main at ea0b6d4, confirmed in a clean worktree. Unrelated. tsc --noEmit clean; biome check clean on every touched file.

syucream and others added 3 commits July 30, 2026 10:00
The API client could only ever send `X-N8N-API-KEY`, so pointing
`N8N_API_URL` at a gateway that authenticates callers per request got the
request rejected at the edge — before n8n was involved at all. There was no
seam to attach a credential, which left operators either bypassing the
gateway or running a local relay process just to add two headers. Both
defeat the point of putting the gateway there.

Four changes make the ordinary commands work through such a gateway:

- Client takes an egress middleware chain, wired from
  `N8N_CLIENT_MIDDLEWARES`. These are the same middlewares the proxy
  subcommand already runs on its own egress, so there is one implementation
  and one config vocabulary for both directions. Empty by default: the
  direct-to-n8n path is unchanged.

- New `iap-auth` token source `adc-impersonate` mints the id_token as a
  target service account using local Application Default Credentials. The
  metadata source only covers workloads; a developer machine had no way to
  produce the token except pasting `gcloud print-identity-token` output into
  an env var every hour.

- `impersonator-token` no longer requires an explicit audience: the ADC
  source names its own, because the refresh-token grant only issues tokens
  for the client that owns the credentials (asking for another project's
  client fails with `invalid_audience`). A configured constant that doesn't
  match the credentials in use fails at the far end with nothing but
  "verification failed"; deriving it removes that failure class. `iap-auth`
  likewise defaults its audience to the API URL, which is what a Cloud Run
  gateway expects.

- `N8N_API_KEY` becomes optional when an egress chain is configured. A
  gateway that terminates authentication holds the key and injects it
  upstream, so demanding one anyway pushes people to park a placeholder — or
  the real shared key — in every developer's shell.

Requests now also ask for `Accept-Encoding: identity`. A proxy commonly
re-emits the upstream's `Content-Encoding` over a body its own HTTP client
already decoded; that mismatch reaches us as an unrecoverable decompression
failure. Uncompressed transfers cost bandwidth a CLI can afford — a read
path that dies on large responses is not affordable.

Verified end to end through the CLI against a deployed proxy: list, get,
`apply --dry-run` (unchanged), create, update, delete, and a lint-violating
create correctly refused by the gateway with 422.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The authz middleware read its ACL out of the request body, which cannot work
for the one place an ACL is actually visible to a team: n8n tags. Tags are
assigned through a separate endpoint and are absent from every workflow write
payload (`WorkflowInput` carries name/nodes/connections/settings only), so a
tag-based ACL always resolved to "no groups declared" and denied every write.
Where an ACL *did* travel in the body, the caller could grant themselves
access by editing the very request being authorized.

- `aclSource: upstream` reads the stored workflow through a host-provided
  reader (`ctx.fetchStoredWorkflow`) instead of the payload. The proxy wires
  it to a GET under the caller's own API key, so it never escalates
  privileges, and caches per id for `aclCacheTtlMs` (default 10s — a cached
  ACL is a cached permission). An unreachable upstream denies rather than
  reading as "no ACL"; `onError: allow` downgrades that to a warning.
  Default stays `request` so existing configurations are unaffected.

- `onMissingAcl` / `bootstrapGroups` decide the case with nothing to check
  against, which is every `create`. A blanket deny made creates impossible;
  naming groups lets "anyone on the team may create, only owners may edit"
  be expressed.

- `groups.auth` gives the groups request a credential: `bearer-env`, or
  `gcp-id-token` reusing iap-auth's token sources. A groups API worth
  trusting sits behind something, and static headers cannot carry a token
  that expires hourly.

- The proxy's gated surface becomes a route table with actions, covering tag
  assignment, delete and activate — not just create/update. Gating an ACL
  kept in tags while leaving `PUT /workflows/:id/tags` wide open would have
  been decoration. Routes are configurable (`--routes` / `N8N_PROXY_ROUTES`)
  because the surface worth gating is deployment specific; the default table
  reproduces today's behaviour plus the three new endpoints. Routes declare
  whether their body is a workflow, so lint stays away from bodies that
  aren't one, and `authz.actions` scopes which of them it judges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ions

The CLI wiring set `iapAuthAudience` itself, which meant the core request
path named a specific middleware's configuration — the opposite of the
arrangement these middlewares exist to preserve, where authentication is an
opt-in extension the core knows nothing about.

The fallback now lives in the iap-auth factory, which reads N8N_API_URL when
no audience is configured. Same one-variable ergonomics, but the knowledge
flows the right way: a middleware may know about its host, the host must not
know about a middleware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@syucream
syucream merged commit 99421ba into main Jul 30, 2026
4 checks passed
@syucream
syucream deleted the feat/client-outbound-auth branch July 30, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant