feat(client): let the CLI authenticate against a gateway in front of n8n - #51
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Client.doRequestsends a fixed set of headers —X-N8N-API-KEY,Content-Type,Accept. There is no seam to attach anything else, so pointingN8N_API_URLat a gateway that authenticates callers per request fails at the edge: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
Clienttakes an egress middleware chain, wired fromN8N_CLIENT_MIDDLEWARES. These are the sameClientMiddlewares theproxysubcommand 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.iap-authtoken sourceadc-impersonate: mints the id_token as a target service account using local Application Default Credentials (ADC refresh-token grant →iamcredentials.generateIdToken). Themetadatasource only covers workloads; a developer machine previously had no way to produce that token except pastinggcloud auth print-identity-token --impersonate-service-account=...into an env var every hour.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 withinvalid_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-authlikewise defaults its audience to the API URL, which is what a Cloud Run gateway expects.N8N_API_KEYis 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: identityon API requests. A proxy commonly re-emits the upstream'sContent-Encodingover 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:
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 noN8N_API_KEY:workflow list --limit 3workflow get <id>apply --ids=<id> --dry-run1 unchangedworkflow create -f <lint-violating>.json --no-lintWorkflow violates 2 linter rules and was not forwarded to n8nworkflow create -f <clean>.jsonworkflow update <id> -f <edited>.jsonworkflow getshows the editworkflow delete <id> --forceNotably the read path succeeded against a gateway whose own build predates the
Content-Encodingfix in #50 — theidentityrequest 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) andtests/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.tsalso fails onorigin/mainat ea0b6d4, confirmed in a clean worktree. Unrelated.tsc --noEmitclean;biome checkclean on every touched file.