Skip to content

Test sign-in against a real proxy: oauth2-proxy and Dex in CI #717

Closed

Description

@V3RON

Part of #701.

Scope

A CI lane runs Simlock behind a real sign-in proxy (oauth2-proxy) and a real identity provider (Dex), both in Docker, and signs a person in through them in a real browser. It proves the claims, header and key format of a real proxy match what Simlock checks, which the other lanes only imitate. It runs on pull requests that change source code, and once a week. It runs on Linux only.

In short

Nothing changes for a user. CI gains a "Real proxy" workflow. A person logs in on Dex's page, oauth2-proxy passes their ID token to a Simlock gateway, and the console opens with their name.

flowchart LR
  B[Playwright browser] --> O["oauth2-proxy :4180"]
  O -->|login| D["Dex :5556"]
  O -->|X-Id-Token: ID token| G["Simlock gateway :4700"]
  W[Simlock worker] -->|/v1/uplink, skip-auth route| O
  G -->|keys| D
Loading

All on 127.0.0.1 of the runner: the two containers use host networking, so the browser, oauth2-proxy and Simlock reach Dex at the same issuer URL, http://127.0.0.1:5556/dex, and Simlock's jwksUrl (http://127.0.0.1:5556/dex/keys) is a loopback address.

Word: Dex, an open-source identity provider that can run with a fixed user list, used here as the team's identity provider.

If this goes wrong, a user would notice nothing directly; a broken lane would hide a real-proxy regression, or a flaky one would waste CI time.

Technical spec

Facts

Commit ccb50353. oauth2-proxy v7.15.5 and Dex v2.46.0, read in their sources at those tags.

# Fact Source
F1 CI runs on ubuntu-24.04 runners .github/workflows/ci.yml:23
F2 CI triggers are pull_request and push to main; no schedule .github/workflows/ci.yml:3-6
F3 The console lane's path filter counts any non-test file under src/ or ui/, the console specs and helpers, and the build files .github/workflows/ci.yml:147-182
F4 No lane uses Docker today rg -i docker .github e2e scripts docs/internal/agent-rules/toolchain.md: no hit
F5 oauth2-proxy's alpha config injectRequestHeaders can put the session's ID token in a header we name (claimSource.claim: id_token); with preserveRequestValue: false a client-sent copy is stripped first oauth2-proxy pkg/apis/sessions/session_state.go:143-147; pkg/middleware/headers.go:28-58,76
F6 --pass-authorization-header strips the request's own Authorization (or, with --skip-auth-strip-headers=false, adds a second value beside it) oauth2-proxy pkg/apis/options/legacy_options.go:254-260,380-383; pkg/middleware/headers.go:76
F7 A skip-auth route is let through with no session, still through the header chain oauth2-proxy oauthproxy.go:1076,1168-1170
F8 Without --skip-provider-button oauth2-proxy serves its own sign-in page; with it, it redirects to the provider oauth2-proxy oauthproxy.go:1080-1094
F9 Dex's ID token carries email with the email scope and name (from the password entry's username) with the profile scope Dex server/tokens/issuer.go:144-153
F10 Dex's sub encodes (user id, connector id), not the configured userID Dex server/tokens/claims.go:138-146
F11 Dex serves its keys at <issuer>/keys Dex server/discovery/discovery.go:48,139
F12 A Dex static password has email, hash, username, name, userID Dex storage/storage.go:567-600
F13 The gateway listens on 127.0.0.1:4700 by default src/core/config.ts:764
F14 The console lane sets a 60 s test timeout playwright.config.ts:13
F15 The worker sends its join token as Authorization: Bearer src/ports/uplink-websocket.ts:283
F16 #714's proxy strategy takes any header name; #716's console shows "Signed in as " #701 Shared values
F17 With --alpha-config, oauth2-proxy parses the command line with its core flag set only: skip-auth-route, skip-provider-button, email-domain and the cookie flags are there; provider, oidc-issuer-url, scope, upstream, http-address are not and live in the alpha config (providers, upstreamConfig, server) oauth2-proxy main.go:126-160; pkg/apis/options/options.go:118-131
F18 oauth2-proxy refuses to start without email-domain (or an emails file) oauth2-proxy pkg/validation/options.go:52
F19 oauth2-proxy runs OIDC discovery at start and exits if the issuer does not answer; its image has no shell oauth2-proxy Dockerfile:7,57-59,74
F20 Dex shows a "Grant Access" page unless oauth2.skipApprovalScreen: true; static users need enablePasswordDB: true; a client lists its redirectURIs; the login field is the email Dex config.yaml.dist:125,147,202,208; cmd/dex/config.go:256; storage/storage.go:567-600
F21 The console fixture's startDaemon picks its own free port and asserts the console URL on it e2e/console/fixtures.ts:77,83,86,127

Modules touched

Search: rg 'playwright' package.json playwright.config.ts .github.

  • New .github/workflows/real-proxy.yml: triggers pull_request, schedule weekly, workflow_dispatch. A changes job like the console lane's (F3) runs the lane job when a non-test file under src/ or ui/, e2e/real-proxy/**, .github/workflows/real-proxy.yml, package.json or pnpm-lock.yaml changed; otherwise the lane job is skipped by its if. Not part of the Quality check.
  • New e2e/real-proxy/:
    • compose.yaml: Dex v2.46.0 and oauth2-proxy v7.15.5, each pinned by digest, both network_mode: host.
    • dex.yaml: issuer http://127.0.0.1:5556/dex; enablePasswordDB: true; oauth2.skipApprovalScreen: true; one static password (email alice@example.test, username Alice, a bcrypt hash of a fixed test password); one static client simlock-real-proxy with a fixed secret and redirectURIs: [http://127.0.0.1:4180/oauth2/callback].
    • oauth2-proxy.yaml (alpha config): providers (one oidc provider, the client id and secret, issuer http://127.0.0.1:5556/dex, scope openid email profile), upstreamConfig (http://127.0.0.1:4700/, WebSockets on), server.bindAddress 127.0.0.1:4180, injectRequestHeaders X-Id-Token from id_token with preserveRequestValue: false.
    • The lane's own starter for the gateway (fixed port 4700) and a worker, not the console fixtures (F21).
    • real-proxy.spec.ts; playwright.real-proxy.config.ts (60 s test timeout, 15 s action timeout).
    • Global setup: check ports 4180, 5556 and 4700 are free; docker compose up -d dex; wait for http://127.0.0.1:5556/dex/.well-known/openid-configuration; docker compose up -d oauth2-proxy; wait for http://127.0.0.1:4180/ping; each wait at most 60 s. Teardown: docker compose down -v.
  • oauth2-proxy flags, core flag set only (F17): --alpha-config=/etc/oauth2-proxy.yaml, --email-domain=* (F18), --cookie-secret=<fixed 32-byte test value>, --cookie-secure=false (plain HTTP on loopback), --skip-provider-button, --skip-auth-route=^/v1/uplink$, --skip-auth-route=^/v1/healthz$.
  • The gateway's http.proxyAuth: header: x-id-token, jwksUrl: http://127.0.0.1:5556/dex/keys, issuer: http://127.0.0.1:5556/dex, audience: the client id.
  • package.json: script test:real-proxy.
  • docs/internal/agent-rules/toolchain.md: the lane, its command, Linux with Docker only, one run per machine (fixed ports).

Contract and event changes

none.

Other code on the same state

The lane's daemons have their own SIMLOCK_HOMEs under a temp directory. Its ports (4180, 5556, the gateway's) are fixed: two runs on one machine collide, so the global setup checks the ports are free and fails naming the busy one.

Failure modes

Call Outcome What happens Limit, counted from
Dex start, then oauth2-proxy start ok tests run 60 s per wait, from each up -d
same an image cannot be pulled, or an address never answers the run fails, naming the container and the address it waited on same
a port in use — the run fails before starting anything, naming the port none
Dex login in the browser page changed the test fails on the selector, with a screenshot 15 s per action
any test too slow fails 60 s per test
the workflow over its limit fails timeout-minutes: 15
teardown always docker compose down -v, daemons stopped —
a scheduled run fails a step looks for an open issue titled exactly "Real-proxy lane failed": comments the run's link on it, or opens it labelled bug:new —

State: none kept between runs.

Rules in play

  • Testing rule 10, prove each claim once at the cheapest level: this lane proves only what needs a real proxy (real claims, header, keys, login); every refusal case stays in Sign in through a sign-in proxy's signed JWT #714's tests.
  • vendor-and-third-party-code.md, nothing fetched at runtime: the images are CI test fixtures, not part of Simlock, pinned by digest.

Tests

Seam: a Playwright browser through oauth2-proxy at http://127.0.0.1:4180, against a fake-driver gateway with the http.proxyAuth above. Model on e2e/console/sign-in.spec.ts.

  • e2e/real-proxy/real-proxy.spec.ts: opening the console through oauth2-proxy lands on Dex's login page; after logging in as Alice the console opens reading "Signed in as Alice".
  • e2e/real-proxy/real-proxy.spec.ts: after that login, GET /v1/identity from the page answers a principal starting proxy:http://127.0.0.1:5556/dex# with a non-empty rest, name Alice, via proxy, role admin.
  • e2e/real-proxy/real-proxy.spec.ts: a worker with gateway.url ws://127.0.0.1:4180 joins through /v1/uplink (its join token passes the proxy) and appears on the console's Workers page.
  • e2e/real-proxy/real-proxy.spec.ts: GET /v1/identity through oauth2-proxy with no session and Accept: text/html gets oauth2-proxy's redirect to Dex, never Simlock's answer.
  • e2e/real-proxy/real-proxy.spec.ts: signed in as Alice, a request through oauth2-proxy carrying a minted agent token answers GET /v1/identity as that token (the token wins; Authorization passes untouched).
  • e2e/real-proxy/ports.test.ts: the setup's port check, with port 4180 held by the test, fails naming 4180; with all free, it passes.
  • The scheduled-failure step has no test: it is a gh call in the workflow, run only by the schedule.

Done when

  • pnpm test:real-proxy passes on Linux with Docker.
  • The "Real proxy" workflow runs on a PR that changes a file under src/, is skipped on one that changes only docs, and has a weekly schedule.
  • docs/internal/agent-rules/toolchain.md lists the lane.

Out of scope

  • Cloudflare Access, Google IAP or Pomerium in CI.
  • Making the lane a required check.
  • Running the lane on macOS.

Depends on

Written by an agent.

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions