Skip to content

Auth checks live in one module, src/auth #712

Closed

Description

@V3RON

Part of #701.

Scope

Every check that turns a credential into an identity moves into one new component, src/auth: the token store, the per-start admin secret, the socket hello's credential check, the uplink's join-token check and the HTTP request check. HTTP requests are checked by an ordered list of strategies, with one strategy today: Simlock tokens. Nothing a user sees changes. After this PR a second way to sign in is one new strategy directory and one registry line.

In short

Nothing changes for a user: the same tokens, the same answers, the same messages. Inside, the token check that lives in four places today lives in one, and HTTP asks a list of strategies instead of the token store.

flowchart LR
  H[HTTP request] --> A["src/auth: strategy list (changed)"]
  S[socket hello] --> A
  U[uplink upgrade] --> A
  A --> T[token strategy]
Loading

A strategy is one way to sign in. It answers identified (here is the identity), not-mine (my kind of credential is absent, try the next), rejected (my credential is present and bad, stop with 401) or forbidden (my credential is real but has no authority here, stop with 403).

If this goes wrong, a user would notice a token, the admin secret or a join token that worked yesterday being refused.

Technical spec

Facts

Commit ccb50353.

# Fact Source
F1 HTTP auth reads only Authorization: Bearer; no header → 401 "Missing bearer token"; unknown → 401 "Unknown bearer token"; a worker token → 403 "A worker join token can only open an uplink, not call the HTTP API" src/http/auth.ts:43-58
F2 TokenIdentity is { requesterId, role }, role one of agent, operator, worker; verify re-reads tokens.json each call src/http/token-store.ts:15,29-32,103-113
F3 Every Simlock token secret starts slk_ src/ports/token-secrets.ts:8,13
F4 HTTP principal is the token id; operator → admin, else agent src/http/dispatcher-session.ts:24-26,74-77
F5 Socket hello: no credential → agent; operator token or admin secret → admin; else AdminAuthenticationFailedError src/daemon/session.ts:65-76
F6 The admin secret's hash lives in memory; plaintext written to admin.token mode 0600 after the socket claim src/daemon/admin-secret.ts:40-58,71-73
F7 Uplink upgrade check is inline in main.ts: no or unknown token → unauthenticated; not worker → forbidden; else accept with the token id src/daemon/main.ts:741-751
F8 main.ts builds TokenStore and AdminSecretManager twice: worker path and gateway path src/daemon/main.ts:394-409,712-727
F9 token.create/list/revoke handlers use the store on both dispatchers; the gateway has a structural copy GatewayTokenStore src/daemon/dispatcher.ts:962-973; src/gateway/dispatcher.ts:86-93
F10 TokenIdentity is imported by 15 non-test files under src/agent-device and by src/http/tracker.ts; they read requesterId and compare role with "agent". src/stream/mounts.ts declares its own { requesterId, role } rg -l 'TokenIdentity' src -g '!*.test.ts'; src/agent-device/front/front.ts:383; src/http/tracker.ts:193,226-227,326; src/stream/mounts.ts:9,24-25
F11 AdminSecretManager and createCredentialRoleResolver are also imported by src/cli/index.test.ts src/cli/index.test.ts:30-31
F12 A directory under src/ with an index.ts is a component, entered only through index, testing or definition .dependency-cruiser.mjs:4-11,26-35
F13 dependency-cruiser rules are proved in src/lint-rules.test.ts with cruise(...) and the violation's rule.name src/lint-rules.test.ts:577-610
F15 src/daemon/error-code.ts imports AdminAuthenticationFailedError as a value and maps it to ADMIN_AUTHENTICATION_FAILED; server.ts defaults to resolveAgentRole src/daemon/error-code.ts:50,87; src/daemon/server.ts:59,354
F16 Capacity strategies are the closest precedent: a definitions registry and one directory per strategy src/core/capacity/strategies/index.ts:3,26,60; src/core/capacity/strategies/{fixed,resource}/index.ts
F17 Moving token-store.test.ts touches its past-index entry .dependency-cruiser-known-violations.json:136
F14 src/http has no index.ts; main.ts imports its files directly src/http/AGENTS.md:2-3

Modules touched

Searches: rg 'TokenStore|TokenIdentity|TokenVerifier|AdminSecretManager|createCredentialRoleResolver|SessionRoleResolver|resolveAgentRole|AdminAuthenticationFailedError|GatewayTokenStore|FakeTokenVerifier' over src e2e ui, and rg 'token-store' .dependency-cruiser-known-violations.json.

flowchart LR
  M["daemon/main.ts (changed)"] --> AU["auth (new)"]
  H["http auth.ts, app.ts, dispatcher-session.ts (changed)"] --> AU
  SV["daemon server.ts, dispatcher.ts (changed)"] --> AU
  G["gateway dispatcher.ts (changed)"] --> AU
  AD["agent-device (type import changed)"] --> AU
  AU --> P[ports]
Loading
  • New src/auth/: index.ts, identity.ts, strategy.ts (definition and answer types), strategies/index.ts (registry), strategies/token/index.ts, strategies/conformance.test.ts, token-store.ts (from src/http), admin-secret.ts (from src/daemon), hello.ts (from src/daemon/session.ts), uplink.ts (from main.ts:741-751), testing.ts, AGENTS.md.
  • src/http: auth.ts calls authenticateRequest and maps AuthError; dispatcher-session.ts, app.ts, tracker.ts take Identity; test-fakes.ts loses FakeTokenVerifier; app.test.ts, core-routes.test.ts, server.test.ts use fakeAuth.
  • src/daemon: main.ts calls createAuth once, before the mode branch; server.ts, dispatcher.ts, error-code.ts take auth's exports; session.ts and admin-secret.ts removed.
  • src/gateway: dispatcher.ts and index.ts:20 drop GatewayTokenStore for auth's token operations type.
  • src/agent-device (15 files that import TokenIdentity, plus front/renewals.ts:53): import Identity from ../auth/index.js; requesterId → principal.
  • src/modules/types.ts: ModuleIdentity keeps its own shape (rule modules-imports-little bars it from src/auth), renamed to { principal, role }; src/stream/mounts.ts:9,24 follows.
  • src/core/config.ts: validates the registered strategies' config keys (Surface).
  • Tests that import a moved name: src/cli/index.test.ts (F11), src/http/auth.test.ts, src/http/tracker.test.ts, src/gateway/dispatcher.test.ts:13,85, src/daemon/server.test.ts, src/daemon/dispatcher.test.ts, and the agent-device tests (rg -l "TokenIdentity|TokenVerifier|GatewayTokenStore|AdminSecretManager|createCredentialRoleResolver|token-store\.js|admin-secret\.js" src -g '*.test.ts').
  • .dependency-cruiser.mjs, .dependency-cruiser-known-violations.json, src/lint-rules.test.ts, docs/internal/ARCHITECTURE.md (module table, Dependencies table).

Surface and extension points

src/auth/index.ts exports createAuth(options), the types below, the strategy registry (for src/core/config.ts, below), and two values the daemon already uses: AdminAuthenticationFailedError (mapped by src/daemon/error-code.ts:50,87) and resolveAgentRole (the default in src/daemon/server.ts:59,354). options: { config, dataDirectory, filesystem, secrets, idGenerator, clock, logger }, where config is AuthConfig, the http block typed from the contract's configSchema (z.infer), as GatewayConfig is (src/gateway/dispatcher.ts:79). It returns Auth:

Member Used by Does
authenticateRequest(headers) → Identity, or throws AuthError src/http/auth.ts runs the strategies in registry order (one today: tokens)
resolveHelloRole(hello) src/daemon/server.ts F5, unchanged
authenticateUplink(credential) uplink listener in main.ts F7, unchanged
tokens (create, list, revoke) both dispatchers F9, unchanged
adminSecret (persist, remove) src/daemon/server.ts F6, unchanged

AuthError is { status: 401 | 403, message, details? }, defined in src/auth; src/http/auth.ts turns it into HttpApiError(status, status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN", message, details) (src/http/errors.ts:50-66), so src/auth never imports src/http. details goes into the body beside code and message; no token answer has any, so every token body stays as today.

Identity is { principal, name, role: "operator" | "agent", via, expiresAt?, ambient, signOutUrl? }. via is the name of the strategy that identified the request.

The extension point, in the shape every extension point has (architecture rule 20). ADR 0033 names the second implementation (the proxy JWT, #714).

  • AuthStrategyDefinition: name; config? = { key, schema, defaults } (the http sub-key it owns, a zod schema, its defaults; the token strategy has none); enabled(config) (true when its key is set; the token strategy is always on); create(deps) → AuthStrategy, where deps is AuthDeps { config, tokens, clock, logger }. Exported from src/auth/index.ts as a type.
  • AuthStrategy.authenticate({ headers }) → one of: { kind: "identified", identity }; { kind: "not-mine", sawCredential } (its kind of credential is absent; sawCredential true when the request carried an Authorization bearer value it did not claim); { kind: "rejected", message, details? } (401); { kind: "forbidden", message } (403: the credential is real but has no authority here). The message and details of a refusal come from the strategy, never from the host.
  • src/core/config.ts validates each registered definition's config.key under http with its schema and fills its defaults, turning a schema issue into invalidValue (src/core/validation.ts:17-18). The contract cannot import src/auth, so configSchema's http mirrors the definitions' schemas by hand and a test in src/auth checks they match (architecture rule 20's exception for a boundary).
  • One registry, src/auth/strategies/index.ts: an ordered list of definitions keyed by name. Order is the order they are asked. Today: token.
  • One directory per strategy: src/auth/strategies/token/index.ts.
  • One conformance suite, src/auth/strategies/conformance.test.ts, describe.each over the registry. Each strategy directory has a testing.ts exporting its fixture { config, deps(), goodRequest() }. The suite checks: with no credential of its kind a strategy answers not-mine; goodRequest() is identified with a non-empty principal and name and via equal to the strategy's name.
  • createAuth builds the strategies whose enabled(config) is true; no host and no line of createAuth names one. src/auth/testing.ts exports createAuthWith(definitions, options), which takes a registry of fakes, and fakeAuth(identities), a map from bearer secret to Identity, for the src/http suites that today use FakeTokenVerifier with several tokens (src/http/app.test.ts:43-46).

The token strategy (src/auth/strategies/token). Its credential is an Authorization value of exactly Bearer followed by a non-empty value (trimmed), as extractSecret reads it today (src/http/auth.ts:19-23), whose value starts slk_ (F3).

  • No such value → not-mine (sawCredential true when the value is a non-empty Bearer that does not start slk_).
  • Verifies → identified: principal the token id, name its label, else its id, role its role, via: "token", ambient: false.
  • Verifies as a worker token → forbidden with today's message (F1).
  • Does not verify → rejected, message "Unknown bearer token", no details.

When every strategy answers not-mine: 401 "Unknown bearer token" if any answer had sawCredential, else 401 "Missing bearer token". These keep today's answers:

Authorization Today After
absent 401 Missing 401 Missing
Basic x 401 Missing 401 Missing
Bearer (empty) 401 Missing 401 Missing
bearer slk_… (lower-case scheme) 401 Missing 401 Missing
Bearer slk_<unknown> 401 Unknown 401 Unknown
Bearer abc (no slk_) 401 Unknown 401 Unknown
Bearer slk_<worker token> 403 403
Bearer slk_<agent token> identified identified

New lint rule auth-imports-little (dependency-cruiser): src/auth/ (not tests) imports only src/ports, src/contract, Node built-ins and npm packages (the moved token-store.ts uses node:path). past-index already keeps everyone else out of src/auth/strategies/ (F12).

Contract and event changes

none. rg of src/contract for the moved names: no hit. The wire is unchanged; no protocol bump.

Other code on the same state

State: tokens.json, admin.token, the hello role. Search: rg -l 'tokens\.json|admin\.token' src -g '!*.test.ts': 16 files, 30 hits. Code (not manuals or comments only): the moved files, main.ts, server.ts, dispatcher.ts, and the CLI side src/cli/connect.ts, src/cli/environment.ts.

  • token.create/revoke (both dispatchers) and verify share tokens.json; the concurrency fix from fix(http): preserve concurrent token mutations #616 lives in TokenStore and moves with it unchanged.
  • The CLI reads admin.token to send it at hello (src/cli/connect.ts). The file's path, format and mode do not change, so the CLI is untouched.
  • usage.get and simlock stats from the projections #631 (usage.get) reads token labels through the token store on both dispatchers. This task goes after it and moves that use with the rest: the labels come from Auth.tokens.list().
  • No task of this feature writes tokens.json or admin.token.

Failure modes

Call Outcome What happens Limit, counted from
authenticateRequest → token strategy → tokens.json read ok identity none (file read, as today)
same file unreadable or malformed throws as today (TokenStoreError) → 500 INTERNAL as today
resolveHelloRole bad credential ADMIN_AUTHENTICATION_FAILED, socket closed, as today as today
authenticateUplink no / unknown / non-worker token 401 / 401 / 403, worker.rejected, as today as today

State: none new. A restart mid-step leaves what it leaves today.

Rules in play

  • Architecture rule 10, "A rule is enforced in exactly one place": every token check moves into src/auth.
  • Rule 18, "A module hands out a capability, not its parts": createAuth returns Auth, not the store or strategies.
  • Rule 19, "An extension point is added when a second implementation is named": ADR 0033 names the proxy JWT.
  • Rule 20, "Every extension point has the same shape": definition, registry, one directory per strategy, conformance suite.
  • Rule 21, "A host never names an implementation": src/http sees authenticateRequest only.
  • ADR 0003 §5, credentials "checked in this order": resolveHelloRole keeps the order.
  • ADR 0027: the new rule is a dependency-cruiser rule with a lint test.

Tests

Seam: createAuth and createAuthWith from src/auth, through authenticateRequest; model on src/http/app.test.ts and src/daemon/session.test.ts. Existing suites of src/http, src/daemon, src/gateway, src/agent-device and the e2e lane prove the unchanged behaviour once their imports move.

  • src/auth: each Authorization row of the table in Surface gives its answer and message.
  • src/auth: a known slk_ token is identified with its id as principal, its label as name (its id when it has no label), its role, via: "token", not ambient.
  • src/auth (createAuthWith, two fake strategies): not-mine from the first lets the second identify; rejected and forbidden from the first stop the list and the second is never asked; a rejected answer's message and details become the AuthError's; a definition whose enabled(config) is false is never asked.
  • src/http/auth.test.ts: an AuthError 401 becomes UNAUTHENTICATED with its message and details in the body, a 403 FORBIDDEN; a token's 401 body has only code and message, as today.
  • src/auth: a fake definition with a config schema has its key validated and defaulted by src/core/config.ts, and a bad value is refused with invalidValue naming the key; the contract's configSchema.http matches every registered definition's schema.
  • src/auth/strategies/conformance.test.ts: every registered strategy answers not-mine with no credential of its kind, and its identities carry via equal to its name.
  • src/auth (moved from src/daemon/session.test.ts): hello with no credential is agent; with an operator token, then the admin secret, admin; with anything else AdminAuthenticationFailedError, which src/daemon/error-code.ts still maps to ADMIN_AUTHENTICATION_FAILED.
  • src/auth (moved from src/daemon/admin-secret.test.ts): the admin secret's existing tests, unchanged.
  • src/auth: authenticateUplink answers unauthenticated for none and unknown, forbidden for an operator token, accept with the token id for a worker token.
  • src/lint-rules.test.ts (cruise): a file in src/auth importing src/core/index.ts is an auth-imports-little violation; importing src/ports/index.ts is none.
  • e2e/token-route.test.ts and e2e/gateway-smoke.test.ts pass unchanged (tokens over HTTP, a worker joining with a join token).

Done when

  • rg 'token-store|admin-secret|daemon/session' src --glob '!src/auth/**' finds no import.
  • main.ts calls createAuth once (rg -c 'createAuth\(' src/daemon/main.ts prints 1).
  • pnpm lint passes with the new rule, and its src/lint-rules.test.ts case passes.
  • The tests above pass; pnpm test:e2e e2e/token-route.test.ts e2e/gateway-smoke.test.ts e2e/http-api.test.ts passes.
  • src/auth/AGENTS.md exists; src/http/AGENTS.md, src/daemon/AGENTS.md and docs/internal/ARCHITECTURE.md name src/auth where they named the moved files.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 19, 2026
  2. V3RON commented on Oct 19, 2026

    @V3RON
    ContributorAuthor

    Overlap with #708 (feature #700): #708 rekeys the if (config.mode === "gateway") branch in startDaemon on a role taken from the daemon's folder, and removes the mode config key. This task rewrites the same part of startDaemon. The maintainer decided this task should list #708 under Depends on; its spec session can add the line.

    Written by an agent.

  3. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 19, 2026
  4. self-assigned this
    on Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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