Repository navigation
Test sign-in against a real proxy: oauth2-proxy and Dex in CI #717
Description
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
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: triggerspull_request,scheduleweekly,workflow_dispatch. Achangesjob like the console lane's (F3) runs the lane job when a non-test file undersrc/orui/,e2e/real-proxy/**,.github/workflows/real-proxy.yml,package.jsonorpnpm-lock.yamlchanged; otherwise the lane job is skipped by itsif. 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, bothnetwork_mode: host.dex.yaml: issuerhttp://127.0.0.1:5556/dex;enablePasswordDB: true;oauth2.skipApprovalScreen: true; one static password (emailalice@example.test, usernameAlice, a bcrypt hash of a fixed test password); one static clientsimlock-real-proxywith a fixed secret andredirectURIs: [http://127.0.0.1:4180/oauth2/callback].oauth2-proxy.yaml(alpha config):providers(oneoidcprovider, the client id and secret, issuerhttp://127.0.0.1:5556/dex, scopeopenid email profile),upstreamConfig(http://127.0.0.1:4700/, WebSockets on),server.bindAddress127.0.0.1:4180,injectRequestHeadersX-Id-Tokenfromid_tokenwithpreserveRequestValue: 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 forhttp://127.0.0.1:5556/dex/.well-known/openid-configuration;docker compose up -d oauth2-proxy; wait forhttp://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: scripttest: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/identityfrom the page answers aprincipalstartingproxy:http://127.0.0.1:5556/dex#with a non-empty rest,nameAlice,viaproxy,roleadmin.e2e/real-proxy/real-proxy.spec.ts: a worker withgateway.urlws://127.0.0.1:4180joins 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/identitythrough oauth2-proxy with no session andAccept: text/htmlgets 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 answersGET /v1/identityas that token (the token wins;Authorizationpasses 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
ghcall in the workflow, run only by the schedule.
Done when
pnpm test:real-proxypasses 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.mdlists 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.
Activity
test: Add the real-proxy lane's port check, Dex and oauth2-proxy fixt…
test: Probe free ephemeral ports in the lane's port check and match t…
test: Run Simlock behind a real sign-in proxy and a real identity pro…
test: Add the real-proxy lane's port check, Dex and oauth2-proxy fixt…
test: Probe free ephemeral ports in the lane's port check and match t…