Repository navigation
Auth checks live in one module, src/auth #712
Description
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]
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]
- 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(fromsrc/http),admin-secret.ts(fromsrc/daemon),hello.ts(fromsrc/daemon/session.ts),uplink.ts(frommain.ts:741-751),testing.ts,AGENTS.md. src/http:auth.tscallsauthenticateRequestand mapsAuthError;dispatcher-session.ts,app.ts,tracker.tstakeIdentity;test-fakes.tslosesFakeTokenVerifier;app.test.ts,core-routes.test.ts,server.test.tsusefakeAuth.src/daemon:main.tscallscreateAuthonce, before the mode branch;server.ts,dispatcher.ts,error-code.tstake auth's exports;session.tsandadmin-secret.tsremoved.src/gateway:dispatcher.tsandindex.ts:20dropGatewayTokenStorefor auth's token operations type.src/agent-device(15 files that importTokenIdentity, plusfront/renewals.ts:53): importIdentityfrom../auth/index.js;requesterId→principal.src/modules/types.ts:ModuleIdentitykeeps its own shape (rulemodules-imports-littlebars it fromsrc/auth), renamed to{ principal, role };src/stream/mounts.ts:9,24follows.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 }(thehttpsub-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, wheredepsisAuthDeps { config, tokens, clock, logger }. Exported fromsrc/auth/index.tsas a type.AuthStrategy.authenticate({ headers })→ one of:{ kind: "identified", identity };{ kind: "not-mine", sawCredential }(its kind of credential is absent;sawCredentialtrue when the request carried anAuthorizationbearer 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.tsvalidates each registered definition'sconfig.keyunderhttpwith itsschemaand fills itsdefaults, turning a schema issue intoinvalidValue(src/core/validation.ts:17-18). The contract cannot importsrc/auth, soconfigSchema'shttpmirrors the definitions' schemas by hand and a test insrc/authchecks they match (architecture rule 20's exception for a boundary).- One registry,
src/auth/strategies/index.ts: an ordered list of definitions keyed byname. 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.eachover the registry. Each strategy directory has atesting.tsexporting its fixture{ config, deps(), goodRequest() }. The suite checks: with no credential of its kind a strategy answersnot-mine;goodRequest()isidentifiedwith a non-emptyprincipalandnameandviaequal to the strategy'sname. createAuthbuilds the strategies whoseenabled(config)is true; no host and no line ofcreateAuthnames one.src/auth/testing.tsexportscreateAuthWith(definitions, options), which takes a registry of fakes, andfakeAuth(identities), a map from bearer secret toIdentity, for thesrc/httpsuites that today useFakeTokenVerifierwith 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(sawCredentialtrue when the value is a non-emptyBearerthat does not startslk_). - Verifies →
identified:principalthe token id,nameits label, else its id,roleits role,via: "token",ambient: false. - Verifies as a
workertoken →forbiddenwith 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) andverifysharetokens.json; the concurrency fix from fix(http): preserve concurrent token mutations #616 lives inTokenStoreand moves with it unchanged.- The CLI reads
admin.tokento send it at hello (src/cli/connect.ts). The file's path, format and mode do not change, so the CLI is untouched. usage.getandsimlock statsfrom 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 fromAuth.tokens.list().- No task of this feature writes
tokens.jsonoradmin.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":
createAuthreturnsAuth, 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/httpseesauthenticateRequestonly. - ADR 0003 §5, credentials "checked in this order":
resolveHelloRolekeeps 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: eachAuthorizationrow of the table in Surface gives its answer and message.src/auth: a knownslk_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-minefrom the first lets the second identify;rejectedandforbiddenfrom the first stop the list and the second is never asked; arejectedanswer's message and details become theAuthError's; a definition whoseenabled(config)is false is never asked.src/http/auth.test.ts: anAuthError401 becomesUNAUTHENTICATEDwith its message and details in the body, a 403FORBIDDEN; a token's 401 body has onlycodeandmessage, as today.src/auth: a fake definition with aconfigschema has its key validated and defaulted bysrc/core/config.ts, and a bad value is refused withinvalidValuenaming the key; the contract'sconfigSchema.httpmatches every registered definition's schema.src/auth/strategies/conformance.test.ts: every registered strategy answersnot-minewith no credential of its kind, and its identities carryviaequal to its name.src/auth(moved fromsrc/daemon/session.test.ts): hello with no credential isagent; with an operator token, then the admin secret,admin; with anything elseAdminAuthenticationFailedError, whichsrc/daemon/error-code.tsstill maps toADMIN_AUTHENTICATION_FAILED.src/auth(moved fromsrc/daemon/admin-secret.test.ts): the admin secret's existing tests, unchanged.src/auth:authenticateUplinkanswers 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 insrc/authimportingsrc/core/index.tsis anauth-imports-littleviolation; importingsrc/ports/index.tsis none.e2e/token-route.test.tsande2e/gateway-smoke.test.tspass 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.tscallscreateAuthonce (rg -c 'createAuth\(' src/daemon/main.tsprints 1).pnpm lintpasses with the new rule, and itssrc/lint-rules.test.tscase passes.- The tests above pass;
pnpm test:e2e e2e/token-route.test.ts e2e/gateway-smoke.test.ts e2e/http-api.test.tspasses. src/auth/AGENTS.mdexists;src/http/AGENTS.md,src/daemon/AGENTS.mdanddocs/internal/ARCHITECTURE.mdnamesrc/authwhere they named the moved files.
Out of scope
- Any new strategy (Sign in through a sign-in proxy's signed JWT #714),
identity.get(Ask Simlock who you are: identity.get, GET /v1/identity, simlock whoami #713), any change to an answer, message or file format. - Salting token hashes, or any change to how secrets are made.
Depends on
Written by an agent.
Activity
V3RON commentedon Oct 19, 2026
Overlap with #708 (feature #700): #708 rekeys the
if (config.mode === "gateway")branch instartDaemonon a role taken from the daemon's folder, and removes themodeconfig key. This task rewrites the same part ofstartDaemon. The maintainer decided this task should list #708 under Depends on; its spec session can add the line.Written by an agent.
test: Auth checks live in one module, src/auth (#712)
feat(auth): Auth checks live in src/auth; the token strategy, hello, …
chore(auth): prose no longer names the moved files (#712)
chore(auth): drop re-exports and fallow suppressions nothing needs (#712
test(auth): uplink refuses any non-worker token, bearer value is trim…
docs(auth): CLAUDE.md beside the manual, full paths for files in othe…
test(auth): pin hello's check order, rethrow of non-auth errors and c…
docs(auth): manuals' Uses lines name auth, createAuth startup order, …
test(real-proxy): run Simlock behind oauth2-proxy and Dex in CI (#740)