Skip to content

Sign in through a sign-in proxy, and know who did what #701

Closed
6 of 6 issues completed
Closed

Description

@V3RON

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]
Loading

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-Assertion header.
  • 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_9f2c for 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

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/identity and simlock whoami say 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

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"]
Loading
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:

Written by an agent.

Activity

  1. added
    feature:specSpec in progress, business and technical together.
    on Oct 19, 2026
  2. V3RON commented on Oct 19, 2026

    @V3RON
    ContributorAuthor

    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.

  3. added
    feature:plannedSplit into tasks. Never picked up itself.
    and removed
    feature:specSpec in progress, business and technical together.
    on Oct 19, 2026
  4. V3RON commented on Oct 19, 2026

    @V3RON
    ContributorAuthor

    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.

  5. V3RON commented on Oct 19, 2026

    @V3RON
    ContributorAuthor

    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.

  6. V3RON commented on Oct 19, 2026

    @V3RON
    ContributorAuthor

    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.

  7. github-actions commented on Oct 19, 2026

    @github-actions

    All 6 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  8. github-actions commented on Oct 19, 2026

    @github-actions

    All 6 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  9. 600me commented on Oct 20, 2026

    @600me

    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/identity response {"error":{"code":"UNAUTHENTICATED","message":"Missing bearer token"}} while a valid Cf-Access-Jwt-Assertion header 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 token while the same JWT decodes with the expected iss, aud and exp, the credential is valid and Simlock simply has no proxy strategy — confirming the gap rather than a bad token.

    What to change.

    1. Implement ADR 0033 as one auth module with pluggable strategies returning 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.
    2. Add the proxy strategy: read http.proxyAuth (header, jwksUrl, issuer, audience), fetch and cache JWKS, verify signature/exp/iss/aud, and map email → principal proxy:<issuer>#<sub-or-thumbprint>, role operator. Reject service tokens (no email) as not-mine so they fall through to token auth.
    3. Surface reasons (expired, unknown-key, keys-unavailable, wrong-audience) in the 401 body and render them on the console token form; have the console call /v1/identity on load and skip the form on success.
    4. Add the 403 CSRF guard for proxy-authenticated mutating requests (Origin/Sec-Fetch-Site check against the console origin).
    5. Add simlock whoami and the via field; end event/lease streams when the JWT exp passes.
    6. Validate against a real oauth2-proxy in CI, and treat keys-unavailable as 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature:plannedSplit into tasks. Never picked up itself.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions