Repository navigation
Sign in through a sign-in proxy, and know who did what #701
Description
Request: none
Problem
Signing in to Simlock always means pasting a Simlock token. A team that already puts its internal tools behind a sign-in proxy (Cloudflare Access, oauth2-proxy and the like) has proved who a person is before the request reaches Simlock, yet Simlock ignores that and asks for a token anyway. The console cannot show who is signed in either.
The code that turns a credential into an identity is spread over the HTTP frontend, the daemon and the gateway, so adding a second way to sign in today means touching all three, and the next one would too.
Who it is for
An operator who runs a Simlock gateway (or a single host with HTTP on) behind the team's sign-in proxy, and the people on that team who open the console. Agents are not in this group: they keep using Simlock tokens.
Outcome
- A person the proxy has signed in opens the console and is in, with no token to paste. Their name is in the sidebar.
- Anyone can ask "who am I" over HTTP (
GET /v1/identity) or the CLI (simlock whoami) and get the identity Simlock sees. - A Simlock token keeps working everywhere it works today, behind the proxy or not.
- An identity the proxy did not sign, or signed wrongly, is refused, and the console says why.
How it works for the user
flowchart LR
O[Open the console URL] --> P{Signed in at the proxy?}
P -->|no| L[Proxy's own login page] --> O
P -->|yes| I[Console asks: who am I?]
I -->|a person| C[Console opens, name in the sidebar]
I -->|refused, with a reason| F[Token form, with a line saying why]
I -->|nobody| G[Token form, as today]
Before, through Cloudflare Access, signed in:
$ curl -s https://simlock.team.dev/v1/identity
{"error":{"code":"UNAUTHENTICATED","message":"Missing bearer token"}}
After:
$ curl -s https://simlock.team.dev/v1/identity
{"principal":"proxy:https://team.cloudflareaccess.com#7f3a…","name":"alice@team.dev","role":"admin","via":"proxy","signOutUrl":"https://team.cloudflareaccess.com/cdn-cgi/access/logout"}
$ simlock whoami
Principal: claude-1
Name: claude-1
Role: agent
Via: socket
The operator turns it on with four keys, set together:
$ simlock config set http.proxyAuth '{"header":"cf-access-jwt-assertion","jwksUrl":"https://team.cloudflareaccess.com/cdn-cgi/access/certs","issuer":"https://team.cloudflareaccess.com","audience":"4f1c…"}'
$ simlock daemon stop && simlock daemon start
Examples
What a request through the proxy gets:
| Request carries | Today | After |
|---|---|---|
| nothing | 401 "Missing bearer token" | 401 "Missing bearer token" |
a Simlock token (slk_…) |
that token's identity | that token's identity |
| a Simlock token and a good proxy JWT | the token's identity | the token's identity |
| a good proxy JWT for a person | 401 "Missing bearer token" | the person, as an operator |
a proxy JWT with no email (a service token) |
401 | judged by its Simlock token; none → 401 "Missing bearer token" |
| an expired proxy JWT | 401 | 401, reason expired |
| a proxy JWT signed by another key | 401 | 401, reason unknown-key |
| a good proxy JWT, proxy keys unreachable | 401 | 401, reason keys-unavailable |
a good proxy JWT on a POST from another site |
401 | 403 |
What could go wrong
- The proxy's key URL is down: people behind the proxy cannot sign in until it is back. Token sign-in still works. Likely only during a proxy outage.
- The operator copies the wrong audience or issuer: every person is refused, and the console shows the reason. Likely on first setup.
- A gateway and its workers are on different versions: the workers show as incompatible until all are upgraded, as with every release that changes the protocol.
Words used
- sign-in proxy: a service in front of Simlock that signs people in with the team's identity provider and lets only them through, e.g. Cloudflare Access, oauth2-proxy.
- JWT: a signed token the proxy adds to each request it lets through, naming the person, e.g. the
Cf-Access-Jwt-Assertionheader. - key URL (JWKS URL): the address where the proxy publishes the public keys its JWTs are signed with, e.g.
https://team.cloudflareaccess.com/cdn-cgi/access/certs. - principal: the identity Simlock acts for, e.g.
tok_9f2cfor a token,proxy:https://team.cloudflareaccess.com#7f3a…for a person. - strategy: one way to sign in that Simlock checks, e.g. Simlock tokens, or the proxy's JWT.
Non-goals
- Recording who did an action: Every action leaves a trace of who did it #703.
- A worker joined to a gateway refusing its own HTTP: A worker joined to a gateway does not serve its own HTTP API or console #705.
- Simlock's own login with an identity provider (built-in OIDC).
- Allow-lists, or mapping the provider's groups to roles. Who gets in is the proxy's policy.
- Trusting an unsigned identity header (
X-Forwarded-Email). - Agents signing in through the proxy. They use Simlock tokens.
- A name on leases (
ownerName). - Ending a live screen stream when the proxy's JWT expires (only event streams end).
Completion conditions
- Behind a sign-in proxy, the console opens with no token form and shows the person's name in the sidebar.
GET /v1/identityandsimlock whoamisay who the caller is: a proxy person, a token, a socket session, or a gateway uplink.- A forged, expired, wrong-issuer, wrong-audience or unverifiable proxy JWT is refused with 401 and a reason; the console shows it.
- A request signed in through the proxy that changes something and does not come from the console's own page is refused with 403.
- A Simlock token works as today, and wins when sent beside a proxy JWT.
- A follow stream (the event stream, a lease's or a lease request's events) opened by a proxy person ends when their JWT expires.
- The whole path works with a real oauth2-proxy and identity provider, in CI.
Open questions
none
Decisions
- ADR 0033 — A sign-in proxy is a second way to sign in, and auth is one module (docs(adr): 0033 a sign-in proxy is a second way to sign in, and auth is one module #704): one auth module checks every credential; strategies answer identified, not-mine or rejected; the proxy JWT rules;
identity.get; the CSRF rule; the console's sign-in.
Shared values
| Value or state | Rule | Written by | Read by | Source |
|---|---|---|---|---|
Identity |
{ principal, name, role: "operator" | "agent", via, expiresAt?, ambient, signOutUrl? }; via is the identifying strategy's registry name (token, proxy) |
#712 (token), #714 (proxy) | #713, #714, #715, #716 | ADR 0033 §2 |
| Strategy answer | identified (with Identity), not-mine (with sawCredential), rejected (with message and details, 401), forbidden (with message, 403) |
#712 | #714 | ADR 0033 §2 |
| Strategy registry | src/auth/strategies/, ordered: token, then proxy; a definition carries name, config (key, schema, defaults), enabled, create |
#712, #714 | #713 (via enum test), src/core/config.ts |
architecture rule 20 |
| "Signed in as " | the sidebar line for every session | #716 | #717 | this spec |
X-Id-Token |
the header oauth2-proxy's alpha config puts the ID token in, in the docs example and the CI lane | #716 (docs), #717 | — | this spec |
identity.get copies signOutUrl |
the shared handler copies the session's signOutUrl |
#713 | #716 | this spec |
| Token strategy claims | only a bearer starting slk_ |
#712 | #714 | ADR 0033 §2; src/ports/token-secrets.ts:8 |
| 401 messages, no identity | "Missing bearer token" (no Bearer <non-empty> value), else "Unknown bearer token" |
#712 (kept as today) | #714 | src/http/auth.ts:19-23,45-49 |
identity.get output |
{ principal, name, role: "admin" | "agent", via: "token" | "proxy" | "socket" | "uplink", signOutUrl? } |
#713 | #714 (fills proxy, signOutUrl), #716 |
ADR 0033 §4 |
GET /v1/identity |
agent or operator token, or a proxy person; a worker token is 403 as on every route; answers identity.get |
#713 | #716, #717 | ADR 0033 §4 |
| Protocol bump | one in #713 (identity.get), one in #714 (configSchema.http.proxyAuth); a PR that merges after another bump bumps again |
#713, #714 | — | docs/internal/agent-rules/toolchain.md:156-158 |
http.proxyAuth |
header, jwksUrl, issuer, audience all or none, so set as one JSON object; signOutUrl optional |
#714 | #716 (fixtures), #717 | ADR 0033 §3 |
| Proxy principal | proxy:<iss>#<sub>, at most 255 characters (issuer ≤ 128, sub ≤ 120) |
#714 | #716, #717 | ADR 0033 §3 |
| Refusal reasons | malformed, algorithm, unknown-key, bad-signature, wrong-issuer, wrong-audience, expired, not-yet-valid, keys-unavailable |
#714 | #716 | ADR 0033 §3 |
| 401 body for a refused proxy JWT | { "error": { "code": "UNAUTHENTICATED", "message": "Sign-in proxy identity refused: <reason>", "reason": "<reason>" } } |
#714 | #716 | this spec |
expiresAt |
proxy JWT exp + 60 s, in ms since epoch |
#714 | #714 (stream end) | ADR 0033 §3, §6 |
| CSRF 403 | { "error": { "code": "FORBIDDEN", "message": "Cross-site request refused: a request signed in through a sign-in proxy must come from the console" } } |
#715 | — (the console sends no changing request) | ADR 0033 §5 |
Tasks
flowchart TD
A["#712 Auth module"] --> B["#713 identity.get / whoami"]
B --> C["#714 Proxy JWT sign-in"]
C --> D["#715 CSRF rule"]
C --> E["#716 Console through the proxy"]
E --> F["#717 Real-proxy CI lane"]
- Auth checks live in one module, src/auth #712 Auth checks live in one module — nothing changes for a user; every credential check now lives in
src/auth. Risk: a token, admin secret or join token that worked stops working. - Ask Simlock who you are: identity.get, GET /v1/identity, simlock whoami #713 Ask Simlock who you are —
GET /v1/identityandsimlock whoamishow the caller's identity. Risk: a wrong name or role shown. - Sign in through a sign-in proxy's signed JWT #714 Sign in through a sign-in proxy — a person the proxy signed in reaches the HTTP API with no token. Risk: a forged JWT gets in, or a good one is refused.
- Refuse cross-site changing requests from a proxy sign-in #715 Refuse cross-site requests from a proxy sign-in — another site cannot make a signed-in browser change something. Risk: another site can make a signed-in browser release leases or drain workers.
- The console signs in through the sign-in proxy and shows who is signed in #716 The console signs in through the proxy — the console opens with no token form and shows the name. Risk: the console shows the token form behind a working proxy.
- Test sign-in against a real proxy: oauth2-proxy and Dex in CI #717 Test against a real sign-in proxy — CI runs Simlock behind oauth2-proxy and Dex. Risk: a broken lane hides a real-proxy regression (it is not a required check).
| Completion condition | Task |
|---|---|
| Console opens with no form, name in sidebar | #716 |
/v1/identity and simlock whoami |
#713 (token, socket), #714 (proxy) |
| Bad proxy JWT refused with 401 and reason; console shows it | #714, #716 |
| Cross-site change refused with 403 | #715 |
| Simlock token as today, and wins | #712, #714 |
| Event stream ends at JWT expiry | #714 |
| Real oauth2-proxy in CI | #717 |
Open work in the same area:
- feat(history): the event history is a SQLite file, history.db #694, feat(history): project requests, minutes and device facts into history.db #695 (history.db, event store): change how events are stored. This feature adds no event and changes no payload. No Depends on.
usage.getandsimlock statsfrom the projections #631usage.get(task:ready): reads token labels through the token store Auth checks live in one module, src/auth #712 moves. Auth checks live in one module, src/auth #712 Depends onusage.getandsimlock statsfrom the projections #631, and moves that use with the rest.GET /v1/statsand thesimlock stats --csvledger #632GET /v1/stats: adds a route insrc/http/app.ts. Auth checks live in one module, src/auth #712 keepsagentAuth's name and use, so the route merges either way; whichever merges second takes the base. No Depends on.- The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 (feature One Simlock instance per host #700): replaces
modewith a folder-chosen role andsimlock --gateway config set, in the same startup code,httpconfig section and e2e harness Auth checks live in one module, src/auth #712, Sign in through a sign-in proxy's signed JWT #714 and The console signs in through the sign-in proxy and shows who is signed in #716 change. Sign in through a sign-in proxy, and know who did what #701 goes first: The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 Depends on Auth checks live in one module, src/auth #712 and Sign in through a sign-in proxy's signed JWT #714 and carrieshttp.proxyAuthover to the gateway's folder (added to The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's body by this session). - The event history is a SQLite file, history.db #629, Projections: request, minute and device fact rows #630,
usage.getandsimlock statsfrom the projections #631,GET /v1/statsand thesimlock stats --csvledger #632, A quarantined device says what failed #635, A waiting request says why it is not placed, on a worker #636, A waiting request says why each worker cannot take it, on a gateway #637: each bumps the protocol; a PR that merges after another bump bumps again (toolchain.md). - A gateway sends chosen fleet events to a signed generic webhook #719, A gateway sends fleet events to Slack #720, simlock alerts test checks a destination #721 (feature Gateway sends fleet events to Slack and other webhooks #707, webhooks, task:draft): edit
src/core/config.ts,src/contract/schemas.ts, the gateway's construction insrc/daemon/main.ts, the operations map, and add a second outbound-HTTP port; they bump the protocol too. Sign in through a sign-in proxy, and know who did what #701 goes first: A gateway sends chosen fleet events to a signed generic webhook #719 Depends on Sign in through a sign-in proxy's signed JWT #714 (asked for on A gateway sends chosen fleet events to a signed generic webhook #719, for Gateway sends fleet events to Slack and other webhooks #707's spec session). - Overview and Waiting: a range picker, wait p95, leases granted and three charts #633, Console Usage page #634 (console Overview and Usage pages): touch
ui/screens, not sign-in or the sidebar foot. No Depends on. - Daemon state stores in SQLite (state.db) #624 (state.db, request:new): would move
tokens.json; not started. None. - feat(history): history.db, usage projections, simlock stats, GET /v1/stats and console figures (#623) #626 (ADR 0032): takes number 0032; this feature's ADR is 0033. None.
Written by an agent.
Activity
V3RON commentedon Oct 19, 2026
Spec updated: wrote Who it is for, Outcome, How it works, Examples, What could go wrong, Words used, Non-goals, Completion conditions, Decisions (ADR 0033, #704), Shared values and Tasks (#712-#717).
Written by an agent.
V3RON commentedon Oct 19, 2026
Spec updated: tasks #712-#717 passed the spec check (two rounds and a closure check) and are ready; ADR 0033 (#704) is Accepted, not yet implemented. Order: #631 → #712 → #713 → #714 → {#715, #716} → #717; #708 and #719 come after #714.
Written by an agent.
chore(agents): spec sessions approve once, source facts while asking,…
V3RON commentedon Oct 19, 2026
Delivery run
Merged: none (the stack waits for every task)
In the stack, review passed: #732 (#712), #735 (#713), #736 (#714), #737 (#715), on ADR PR #704 (stack #734)
Parked: #716 — review cap reached with two missing tests (R9, R10) open; PR #738 draft
Waiting: #717 — on #716
Decided: #716 — 302 and 403 reload cases in the same tab → separate tabs, rule kept; #716 — token session behind an ended proxy → keeps today's behaviour; #716 — 5xx/timeout from the identity check after a loss → Reconnecting, not the token form
Written by an agent.
V3RON commentedon Oct 19, 2026
Delivery run
Merged: none
In the stack, review passed: #732 (#712), #735 (#713), #736 (#714), #737 (#715), #738 (#716), #740 (#717), on ADR PR #704 (stack #734)
Parked: the stack — the sync onto main needs force-pushes, which the permission system refused; #704 conflicts with main in
docs/internal/adr/README.md(resolved locally as 83fe1fd7, not pushed)Decided: #716 — 302 and 403 reload cases → separate tabs, rule kept; #716 — token session behind an ended proxy → keeps today's behaviour; #716 — 5xx/timeout after a loss → Reconnecting
Accepted by the maintainer: #716 — R9, R10 left untested
Written by an agent.
docs(adr): 0033 a sign-in proxy is a second way to sign in, and auth …
refactor(auth): auth checks live in one module, src/auth (#732)
feat(identity): identity.get, GET /v1/identity and simlock whoami (#735)
feat(auth): sign in through a sign-in proxy's signed JWT (#736)
feat(http): refuse cross-site changing requests from a proxy sign-in (#…
feat(console): sign in through the sign-in proxy and show who is sign…
test(real-proxy): run Simlock behind oauth2-proxy and Dex in CI (#740)
github-actions commentedon Oct 19, 2026
All 6 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.github-actions commentedon Oct 19, 2026
All 6 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.600me commentedon Oct 20, 2026
Root cause. This is a feature request, not a failure report, so there is no stack trace to diagnose. The observable symptom is the
GET /v1/identityresponse{"error":{"code":"UNAUTHENTICATED","message":"Missing bearer token"}}while a validCf-Access-Jwt-Assertionheader is present. That is expected today: the auth path only has one strategy (Simlock tokens), and it is duplicated across the HTTP frontend, daemon and gateway, so no component parses the proxy JWT. The 403 row in the table is a separate, not-yet-implemented CSRF check on state-changing requests, not a Cloudflare block — do not confuse it with an edge 403.Verify it with one command.
curl -si https://simlock.team.dev/v1/identity -H "Cf-Access-Jwt-Assertion: $(curl -s https://team.cloudflareaccess.com/cdn-cgi/access/certs >/dev/null; echo '<paste-jwt>')"If the response is
401 UNAUTHENTICATED / Missing bearer tokenwhile the same JWT decodes with the expectediss,audandexp, the credential is valid and Simlock simply has no proxy strategy — confirming the gap rather than a bad token.What to change.
identified | not-mine | rejected(reason). Order: Simlock token first (it must win when both are sent), then proxy JWT, then socket/uplink. This removes the three-way duplication and is the prerequisite for everything else.http.proxyAuth(header,jwksUrl,issuer,audience), fetch and cache JWKS, verify signature/exp/iss/aud, and mapemail→ principalproxy:<issuer>#<sub-or-thumbprint>, roleoperator. Reject service tokens (noemail) asnot-mineso they fall through to token auth.expired,unknown-key,keys-unavailable,wrong-audience) in the 401 body and render them on the console token form; have the console call/v1/identityon load and skip the form on success.simlock whoamiand theviafield; end event/lease streams when the JWTexppasses.keys-unavailableas fail-closed with token auth still working.Assumption: the JWT is a standard RS256 Cloudflare Access assertion; if it is EdDSA or uses a custom claim for email, adjust the claim mapping accordingly.
Disclosure: I maintain the open-source proxy cost CLI (https://github.com/600me/check-proxy-tax) and the residential-pool versus managed-unblocker cost comparison: https://scrape.mybskm.top/blog/smartproxy-vs-scraperapi.html?utm_source=gh_issue
feat(daemon): the folder says what a daemon is, and --gateway reaches…