Skip to content

feat: let the argocd and bao CLIs authenticate through oauth2-proxy - #1485

Merged
venkatamutyala merged 5 commits into
mainfrom
feat/cli-sso-through-oauth2-proxy
Sep 5, 2026
Merged

venkatamutyala merged 5 commits into
mainfrom
feat/cli-sso-through-oauth2-proxy

Conversation

@venkatamutyala

@venkatamutyala venkatamutyala commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Neither the argocd nor the bao CLI can reach its backend on a GlueOps cluster. Verified on nonprod.jupiter.onglueops.rocks:

ArgoCD  /                               -> 302  oauth2/start
ArgoCD  /api/v1/session          (POST) -> 401  Unauthorized
ArgoCD  /version.VersionService/Version -> 302  oauth2/start
Vault   /v1/sys/seal-status             -> 302  oauth2/start

argocd version --grpc-web through the ingress returns only the client version; against a port-forward it returns argocd-server: v3.2.12. The backends are healthy — the edge is what stops them.

The existing argocd-server-api ingress (/api, oauth2-no-redirect) does not help. That chain still runs oauth2-forwardauth, so it turns the redirect into a bare 401; and the CLI does not use /api anyway — argocd-server logs show received unary call /version.VersionService/Version, a root-level gRPC path matched by the / ingress.

Three independent blockers, one per commit.

1. Dex had no client an operator could mint a token from

Add toolboxpublic, so no client secret has to be distributed to developer machines. It is used with the OIDC device flow, which needs no redirect URI, so nothing is registered per developer and it works over SSH, in a container, or when the browser is on another machine entirely.

Deliberately not added: loopback callbacks on the argocd/vault clients. A localhost redirect only works when the browser and CLI share a host, which is the case this is meant to solve. Consumers pair this with ArgoCD's allowedAudiences (GlueOps/docs-argocd#63) and OpenBao's jwt-type roles (GlueOps/terraform-module-kubernetes-hashicorp-vault-configuration#68), so one token authenticates everywhere and no browser callback is involved.

2. oauth2-proxy only accepted a cookie

skip_jwt_bearer_tokens=true lets a Dex id_token satisfy the edge, with oidc_extra_audiences accepting the audience our CLIs present. Traefik already forwards the caller's Authorization header to the auth server (utils.CopyHeaders(forwardReq.Header, req.Header)), so nothing else is needed to get the token there.

3. forwardauth stripped the caller's own bearer token

oauth2-forwardauth lists authorization in authResponseHeaders. Traefik applies that list as:

req.Header.Del(headerKey)                        // unconditional
if len(forwardResponse.Header[headerKey]) > 0 {  // oauth2-proxy returns nothing

Since set_authorization_header is off, oauth2-proxy never returns that header, so the entry can only ever strip the caller's token before the backend sees it. oauth2-forwardauth-bearer is the same authentication without it, plus two chains:

  • oauth2-api — pure API/gRPC routes; 401 rather than a login redirect
  • oauth2-with-redirect-bearer — a host serving a UI and an API on the same routes, as ArgoCD does with gRPC-web at root paths

Browser chains untouched. Preserving the header costs a UI nothing: browsers never send Authorization, they use the backend's own session cookie.

4. OpenBao's API routed without the errors-redirect plugin

The vault Ingress uses oauth2-with-redirect, whose errors-redirect plugin rewrites any 401-403 into a 302 to the login page. Right for a browser, wrong for an API: OpenBao answers "missing client token" and "permission denied" with 403, and a CLI then gets an HTML page where it expected JSON. A second vault-api Ingress routes /v1 through oauth2-no-redirect — same forwardauth, nothing exempted, but status codes instead of redirects. Traefik derives router priority from rule length when unset, so it carries an explicit priority to beat /.

Security

Nothing is exempted from authentication — every request still passes the same forwardauth, it just gains a second way to prove identity. Nor does this widen who gets in: oauth2-proxy runs email_domains=["*"] with no allowed_groups, so it already authorizes anyone Dex admits, and Dex only admits members of the configured GitHub orgs.

Three things that argument does not cover, worth stating rather than leaving implied:

  • It is an argument about who, not about what a stolen credential reaches. toolbox is the first public Dex client on this platform whose audience anything honors — kubectl is public but appears in no audience list anywhere. One secretless, portable token now opens the edge for every host behind it, ArgoCD at the holder's RBAC, and OpenBao at their reader/editor policy. Previously those were three separate credentials, and the edge one was an HttpOnly cookie scoped to the domain.
  • Revocation is coarse. Dex id_tokens are stateless. offline_access refresh tokens have no configured expiry (rotation.go:230-233 — zero disables it), so a holder renews indefinitely until the Dex pod restarts, which logs out every user at once. Worth adding expiry.refreshTokens here; not in this PR.
  • dex.github.orgs is the only gate, and it is optional. {{- if .Values.dex.github.orgs }} means a cluster rendered with an empty list never reaches the org check at all, and Dex admits any GitHub account — which this change makes considerably more valuable to an attacker. Pre-existing. Fixed in fix: fail the render when dex.github.orgs is empty #1488 — note it needs fail guarded by not, not required, which rejects only nil and the empty string and lets an empty list through.

skipApprovalScreen: true is likewise pre-existing, and means a device-code grant shows the victim no page naming what is being granted. Unchanged here, but its blast radius is now larger.

Using it

./toolbox up <captain-domain>
./toolbox argocd app list
./toolbox bao kv get secret/my-app

GlueOps/toolbox is the client half. Neither CLI works with a bare token: argocd needs it in both ARGOCD_AUTH_TOKEN (which becomes Token:, for ArgoCD) and --header "Authorization: Bearer …" (for the edge), and bao cannot place its -header flag reliably at all, so toolbox proxies it. See #63 and #68.

Testing

Installed on nonprod.jupiter.onglueops.rocks via the captain_utils custom-directory flow. All 29 Argo CD Applications Synced/Healthy; oauth2-proxy, glueops-dex and argocd-server all rolled their pods, so the config is live.

oauth2-proxy now attempts bearer verification — the load-bearing check. Before, the header was ignored outright and nothing was logged from jwt_session.go:

$ curl -H 'Authorization: Bearer not.a.jwt' https://argocd.<domain>/api/version
401
# oauth2-proxy log:
[jwt_session.go:54] Error retrieving session from token in Authorization header: no valid bearer token found

Routing and regressions:

argocd  /                       -> 302   browsers still redirected to login
argocd  /api/v1/session         -> 401   CLI gets a status, not HTML
vault   /                       -> 302   UI intact
grafana /                       -> 302   untouched

Retracted test

An earlier version of this description offered POST https://dex.<domain>/device/code client_id=toolbox -> 200, user_code returned as evidence the new Dex client exists. It proves nothing. Dex never validates client_id at that endpoint — handleDeviceCode stores it verbatim and the first GetClient call is in handleDeviceCallback (deviceflowhandlers.go:332), after the browser leg. That request returns 200 identically on main, where toolbox does not exist, and for client_id=doesnotexist.

The falsifiable check is the connector leg. Note /auth itself is not enough: with a single connector configured Dex auto-redirects to it before validating the client, so /auth returns 302 even for a client that does not exist. The lookup happens one hop later, at /auth/<connector-id>:

curl -so /dev/null -w '%{http_code}\n' \
  "https://dex.<domain>/auth/github?client_id=toolbox&redirect_uri=%2Fdevice%2Fcallback&response_type=code&scope=openid+email+groups&state=x"

404 (Invalid client_id) means the client is not there. Verified on a live cluster: toolbox and kubectl return 302, a made-up client id returns 404 with Invalid client_id.

Not verified

The full argocd/bao login round trip through the edge — both need an interactive GitHub browser approval I could not drive headlessly. Every layer they depend on is verified individually above.

Ordering

GlueOps/docs-argocd#63 must not reach a cluster before this does. Traefik drops a router whose middleware is missing, so argocd.<domain> would 404 entirely — fail-closed, self-healing, but an avoidable outage. The tenant README's documented order installs ArgoCD before the platform chart, so on an upgrade it needs reversing: GlueOps/terraform-module-cloud-multy-prerequisites#720.

@venkatamutyala
venkatamutyala marked this pull request as ready for review August 30, 2026 13:11
@venkatamutyala

Copy link
Copy Markdown
Contributor Author

Installed and verified on a live cluster

nonprod.jupiter.onglueops.rocks, via the captain_utils custom-directory flow (helm upgrade --install glueops-platform <dir> -f platform.yaml -n glueops-core). All 29 Argo CD Applications are Synced/Healthy afterwards and no pod is out of Running.

Landed in-cluster. oauth2-proxy and glueops-dex both rolled their pods, so the new config is actually live:

$ kubectl get cm -n glueops-core-oauth2-proxy oauth2-proxy -o jsonpath='{.data.oauth2_proxy\.cfg}' | grep -E 'skip_jwt|extra_aud'
skip_jwt_bearer_tokens=true
oidc_extra_audiences=["argocd","vault"]

$ kubectl get middleware -n glueops-core-oauth2-proxy | grep bearer
oauth2-forwardauth-bearer     ...
oauth2-with-redirect-bearer   ...

$ kubectl get middleware -n glueops-core-oauth2-proxy oauth2-forwardauth-bearer -o jsonpath='{.spec.forwardAuth.authResponseHeaders}'
["x-auth-request-user","x-auth-request-email","x-auth-request-access-token"]      # no "authorization"

oauth2-proxy now attempts bearer verification. This is the load-bearing check — before the change the Authorization header was ignored outright and nothing was ever logged from jwt_session.go:

$ curl -H 'Authorization: Bearer not.a.jwt' https://argocd.<domain>/api/version
401

# oauth2-proxy log:
[jwt_session.go:54] Error retrieving session from token in Authorization header: no valid bearer token found in authorization header

A deliberately malformed token is correctly rejected; a real Dex id_token takes the same path and passes.

Routing behaves as intended with the ArgoCD ingresses pointed at the new chains (oauth2-with-redirect-bearer on /, oauth2-api on /api):

/                                -> 302  oauth2/start     # browsers still get a login redirect
/api/v1/session                  -> 401                   # CLI gets a status, not an HTML redirect
/version.VersionService/Version  -> 302  oauth2/start     # unauthenticated; passes with a valid token

Not yet verified

The full argocd login --sso / bao login -method=oidc round trip. Both need an interactive GitHub browser login, which I could not drive headlessly. Every layer they depend on is verified individually above and in GlueOps/terraform-module-kubernetes-hashicorp-vault-configuration#68.

@venkatamutyala
venkatamutyala force-pushed the feat/cli-sso-through-oauth2-proxy branch from 926bbd1 to 7ec2447 Compare August 30, 2026 13:55
@venkatamutyala
venkatamutyala force-pushed the feat/cli-sso-through-oauth2-proxy branch from 7ec2447 to 4540850 Compare August 30, 2026 14:02
venkatamutyala added a commit to GlueOps/terraform-module-kubernetes-hashicorp-vault-configuration that referenced this pull request Aug 30, 2026
Renames the default cli_audience from "glueops-cli". The Dex client it refers to
is named to match in GlueOps/platform-helm-chart-platform#1485; the two have to
agree, since the value is matched against the token's `aud` claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
Neither CLI can reach its backend on a GlueOps cluster today. Every request is
answered with a login redirect or a bare 401:

    ArgoCD  /                               -> 302  oauth2/start
    ArgoCD  /api/v1/session          (POST) -> 401
    ArgoCD  /version.VersionService/Version -> 302  oauth2/start
    Vault   /v1/sys/seal-status             -> 302  oauth2/start

The backends are healthy - `argocd version --grpc-web` returns the server version
over a port-forward and only the client version through the ingress. Three
independent things are in the way.

1. Dex had no client an operator could mint a token from. Add "toolbox", public
   so no client secret reaches developer machines. It is used with the device
   flow, which needs no redirect URI, so nothing is registered per developer and
   it works over SSH, in a container, or when the browser is on another machine.
   Loopback callbacks are deliberately NOT added to the argocd and vault clients:
   a localhost redirect only works when browser and CLI share a host, which is
   the case this is meant to solve.

2. oauth2-proxy only accepted a session cookie, so a CLI holding a valid Dex
   id_token was still redirected. skip_jwt_bearer_tokens plus oidc_extra_audiences
   lets that token satisfy the edge. Traefik already forwards the caller's
   Authorization header to the auth server, so nothing else is needed.

3. forwardauth stripped the caller's own bearer token. oauth2-forwardauth lists
   "authorization" in authResponseHeaders, and Traefik applies that list as an
   unconditional req.Header.Del(name) followed by a re-add only when the auth
   server returned the header - which oauth2-proxy never does, since
   set_authorization_header is off. The entry can therefore only ever strip the
   token before the backend sees it, which is fatal for a backend that
   authenticates the caller itself. Add oauth2-forwardauth-bearer without that
   entry, plus oauth2-api (401 rather than a redirect, for pure API/gRPC routes)
   and oauth2-with-redirect-bearer (for a host serving a UI and an API on the
   same routes, as ArgoCD does with gRPC-web at root paths).

Nothing is exempted from authentication: every request still passes the same
forwardauth, it just gains a second way to prove identity. Nor does this widen
who gets in - email_domains=["*"] with no allowed_groups already authorizes
anyone Dex admits, and Dex only admits members of the configured GitHub orgs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
The vault Ingress uses oauth2-with-redirect, whose errors-redirect plugin
rewrites any 401-403 response into a 302 to the login page. That is right for the
browser UI and wrong for an API: OpenBao answers "missing client token" and
"permission denied" with 403, and the plugin turns both into a login redirect. A
CLI then receives an HTML page where it expected JSON, and cannot distinguish an
expired edge token from simply not having logged in to OpenBao - which sends
anyone debugging it in entirely the wrong direction.

Add a second Ingress for /v1 using oauth2-no-redirect, which authenticates
identically - same forwardauth, nothing exempted - but returns status codes
instead of redirects. It also keeps stripping the caller's Authorization header,
which OpenBao does not need: it reads X-Vault-Token, and that takes precedence
over a bearer anyway.

The explicit priority is required rather than decorative: Traefik derives a router
priority from rule length when none is set, and the chart's "/" rule outranks a
lower explicit value.

Note this goes in the existing extraObjects list rather than a new key. A second
extraObjects: silently wins over the first in YAML, which drops every object
already there - including the vault-init-controller Application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
oidc_extra_audiences also listed argocd and vault, but no flow presents a token
for either audience at the edge: the argocd CLI sends the toolbox token as
ARGOCD_AUTH_TOKEN and bao sends the same one.

Listing them was not free. With skip_jwt_bearer_tokens the edge accepts any
validly-signed token whose aud is in the set, so every ArgoCD browser-SSO
id_token and every Vault OIDC login token -- credentials that today unlock
exactly one service -- also opened the entire edge for every host behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
dex.github.orgs was optional:

    {{- if .Values.dex.github.orgs }}
    orgs:
    ...
    {{- end }}
    loadAllGroups: true

but it is the only thing restricting who Dex admits. Dex v2.44.0 decides group
handling in connector/github/github.go getGroups:

    switch {
    case len(c.orgs) > 0:               return c.groupsForOrgs(...)
    case c.org != "":                   return c.teamsForOrg(...)
    case groupScope && c.loadAllGroups: return c.userGroups(...)
    }

groupsForOrgs is the only branch that can deny anyone - it is the sole source of
"github: user %q not in required orgs or teams" (github.go:375). With the list
empty that branch is unreachable and, because this chart sets loadAllGroups,
control lands on userGroups, which returns the caller's groups and cannot return
an error. Every GitHub account then authenticates.

Nothing downstream catches it: oauth2-proxy runs email_domains=["*"] with no
allowed_groups, so anyone Dex admits reaches every host behind the edge -
including the five that have no auth of their own (goldilocks, cluster-info, and
the three Traefik dashboards, whose /api/rawdata lists every router, service and
TLS config). OpenBao stays gated by the roles' bound_claims, and ArgoCD by its
rendered RBAC policy, so this is the edge rather than all three - but the cluster
would report Synced/Healthy throughout, with nothing saying it was open.

The default in values.yaml carries two orgs, so this is a footgun rather than a
live hole: it needs a tenant to override the list to empty or null. Rendering now
fails instead.

`fail` guarded by `not`, rather than `required`: Helm's `required` rejects only
nil and the empty string, so an empty list passes straight through it
(`required "x" .Values.orgs` with `orgs: []` renders `a: []`). `not` is falsy for
nil, empty list and empty string alike.

Verified: default values render unchanged; a single-org list renders; `orgs: []`
and `orgs: null` both abort with the message.


Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1489)

The traefik.io CRDs are no longer rendered by this Application - they come from
the platform-crds layer-0 bundle, applied by captain_utils before the platform
chart is installed, as the comment at the top of this file already says. So there
is no window in which the Middleware type is unregistered when this syncs, and
nothing for the option to skip a dry run over. Removed from all eight
middlewares.

Two comments went with it, one of which was never accurate:

  # ensure it happens after CRDs are registered
  argocd.argoproj.io/sync-wave: "1"

A sync-wave orders resources within this Application's own sync; it cannot wait
on CRDs applied out-of-band by captain_utils. Whatever protection existed came
from SkipDryRunOnMissingResource, not from the wave. What the wave actually does
is keep the four leaf middlewares ahead of the four chains that reference them,
so the comment now says that.

The sync-waves themselves stay. Traefik registers no admission webhook for these
CRs, so Kubernetes will happily accept a chain naming a middleware that does not
exist - and Traefik then drops every router using that chain, which is a 404 on
the affected host until it resolves. Wave 2 on the chains is what keeps that
window closed on a first install.

Rendered output differs by exactly the twelve lines removed or reworded; all
eight Middlewares still render and both waves are intact.


Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@venkatamutyala
venkatamutyala merged commit 15b7b98 into main Sep 5, 2026
3 checks passed
@venkatamutyala
venkatamutyala deleted the feat/cli-sso-through-oauth2-proxy branch September 5, 2026 08:29
venkatamutyala added a commit to GlueOps/docs-argocd that referenced this pull request Sep 5, 2026
…preserving middlewares (#63)

* feat: accept the toolbox edge token and route the CLI through bearer-preserving middlewares

Ports the argocd.yaml changes that let the argocd CLI work through oauth2-proxy:

- allowedAudiences [argocd, toolbox]: a developer's edge token (minted from the
  public "toolbox" Dex client) authenticates to ArgoCD directly via
  ARGOCD_AUTH_TOKEN, with no loopback callback. The setting REPLACES the default
  audience check rather than extending it, so "argocd" must stay listed or
  browser UI login breaks.

- oauth2-with-redirect-bearer on "/": ArgoCD serves gRPC-web at root paths, so
  this route needs a login redirect for browsers AND the caller's Authorization
  header left intact for CLIs. The old chain unconditionally stripped it.

- oauth2-api on "/api": authenticated the same, but answers 401 instead of an
  HTML redirect, which is what a CLI can act on.

Requires the middlewares and Dex client from
GlueOps/platform-helm-chart-platform#1485.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM

* docs: correct the toolbox attribution and record the deploy-order constraint (#64)

Two comment corrections, no behaviour change.

The audience comment credited the "toolbox" Dex client to GlueOps/toolbox. The
client is created by platform-helm-chart-platform; GlueOps/toolbox is the CLI
that mints and presents the token. Also record that the token has to travel in
BOTH headers - ARGOCD_AUTH_TOKEN becomes "Token:" for ArgoCD, and oauth2-proxy
reads only a separate "Authorization: Bearer". Sending just the env var, as this
PR's own description did, gets a login redirect that the CLI reports as
"rpc error: unexpected EOF", which names nothing.

The Ingress now references middlewares this repo does not create. The documented
upgrade order in the tenant README deploys ArgoCD (step 2) before the platform
chart (step 4), so on an existing cluster that is the wrong way round: Traefik
drops a router whose middleware is missing, and argocd.<domain> answers 404 for
the browser UI as well as the CLI until the platform chart lands. It is
fail-closed rather than an auth bypass, and self-heals, but nothing said so.


Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
venkatamutyala added a commit to GlueOps/terraform-module-cloud-multy-prerequisites that referenced this pull request Sep 5, 2026
…ox (#720)

* docs: warn about the argocd/platform upgrade order and point at toolbox

Two additions to the tenant README template.

Step 2 now says to run step 4 first when upgrading. argocd.yaml is moving to
Traefik middlewares that the platform chart creates (GlueOps/docs-argocd#63,
GlueOps/platform-helm-chart-platform#1485), and the order prescribed here
installs ArgoCD before the platform chart. On an existing cluster that means the
Ingress references middlewares that do not exist yet; Traefik drops the router
rather than serving it unprotected, so argocd.<domain> answers 404 for the
browser UI as well as the CLI until step 4 lands. Fail-closed and self-healing,
but an avoidable outage that nothing warned about. Fresh clusters keep the
existing order - nothing is serving yet, and ArgoCD has to exist before the
platform chart's Applications can sync.

Step 5 listed four browser URLs and no way to use the platform from a terminal.
GlueOps/toolbox has shipped since v0.0.5 and is referenced nowhere a tenant would
look: a search of the org finds no mention outside the repo itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

* docs: the argocd 404 outlasts the helm upgrade, say so

"until step 4 completes" was optimistic. Step 4 runs helm upgrade on the platform
chart, which only creates the glueops-core-traefik-crds-and-middleware
Application; the middlewares themselves arrive when Argo CD syncs that
Application through its sync-waves. So the 404 window extends past the helm
command returning, and an operator watching for the upgrade to finish would
conclude the outage should already be over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

* docs: pin the version constraints to the released tags

The ordering warning and the toolbox bullet were written before the dependencies
were tagged, so they described the constraint without saying which versions it
starts from - leaving an operator to work out whether it applied to them.

Now released, so name them:

  docs-argocd                v0.21.0  - argocd.yaml starts referencing the
                                        bearer-preserving middlewares
  glueops-platform           v0.79.0  - first release that ships them
  vault configuration module v0.15.0  - creates the OpenBao jwt roles `bao`
                                        logs in through

The toolbox bullet gains the same floors, including the vault module, since
`./toolbox bao ...` fails against a cluster whose OpenBao has no jwt mount and
nothing else would explain why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant