Skip to content

feat(quest): token auth on the Quest shim over a shared auth core (#158 G) - #223

Merged
thesprockee merged 34 commits into
mainfrom
claude-quest-tokenauth
Oct 10, 2026
Merged

thesprockee merged 34 commits into
mainfrom
claude-quest-tokenauth

Conversation

@thesprockee

@thesprockee thesprockee commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Refs #158 (package G: token auth on the Quest shim). Tip caba8cb955ef66478fb658e99fdc22e918603d3f.

What it is

  • src/core/: platform-neutral token model, refresh handling, device-code loop, injected HTTP/clock/log. The Windows token_auth module, RefreshAuthToken and auth_token.h call them.
  • src/quest/auth/: Android adapters and Session. Not linked into the sentinel yet; nothing hands the token to the login path (ADR 0003 gate).
    • HTTP: libcurl (>= 7.87, checked at configure; resolved 8.18.0) over OpenSSL. Trust anchors come from the Android CA directories, each certificate parsed and re-encoded, passed as CAINFO_BLOB (CAPATH cannot find Android's old-hash names). Fails closed. https only, no redirects, no proxy env, capped response, interruptible (progress callback + CURLOPT_QUICK_EXIT).
    • Credentials: /data/user/<uid/100000>/<package>/files (package from /proc/self/cmdline); exclusive 0600 temp + fsync + rename + dir fsync; O_NOFOLLOW read. Only the login link is on /sdcard.
    • Public calls of QuestTokenAuth are noexcept; the worker thread is named nevr-auth.
  • Merges origin/claude-quest-hooks (d96bb82), conflict only in the ADR.

Failure policy (what the code does, and the tests pin)

Situation Behaviour
No cache, expired refresh token, or the server refuses the token (400/401/403 whose JSON message is exactly one of the refresh RPC's own errors) device-code login (Refreshing -> AwaitingUser)
Transient: no connection, 5xx, 408, 429, unreadable body (cached refresh or device-code request) retried after 5/15/45/135/300 s; cache kept; player not prompted
Any other 4xx: bare 401/403 (nakama's wrong-http_key answer), 400 missing payload, 404 one attempt, no backoff; cache kept; player not prompted; Error logged with status (not body); Failed until the next recovery attempt
Failed and not final one request every 5 min for the life of the process; one Warning per failure class
Final: device code expired/timed out, link undeliverable, poll refused with a 4xx Failed, no recovery (the player was involved)
Poll transient while the player holds a link waited out until the code's own 5-minute deadline
After login: refresh token expired or refused Expired published, one log line, new device login

PC comparison: PC runs the device flow after one failed refresh and aborts a login on the first failed poll; Quest keeps the cache and does not prompt on transient or held failures.

Differences from the previous Windows behaviour

  • RefreshAuthToken (also called from src/runtime/server/gameserver.cpp) catches every nlohmann::json::exception (was parse_error only); valid JSON of the wrong shape logs "valid JSON of an unexpected shape".
  • The "token refresh rejected" line gains refresh_token_named=0|1; the device-flow log says open_result= (was shellexecute_result=) on every platform.
  • kRefreshLeadSec = kFallbackAccessTokenLifetimeSec = 300; comments now state the equality; static_assert is <=.
  • fix/202-token-refresh (4bdc761) is not in origin/main, not merged.

Gates on tip caba8cb955ef66478fb658e99fdc22e918603d3f (hash is line 1 of each log; /var/tmp/work-nevr-runtime/claude-main/tokenauth/)

flock <build.lock> systemd-run --user --scope -p MemoryMax=12G -- env CMAKE_BUILD_PARALLEL_LEVEL=6 NINJA_JOBS=6 just verify         verify4.log      rc=0  ("verify: OK (mingw-release)")
... just build-android                                                                                                             android4.log     rc=0
... just test-android   (Go ELF tests on the merged tip, incl. the sentinel export allowlist)                                      testandroid4.log rc=0  ("ok github.com/EchoTools/nevr-runtime/tests/quest")

Inside verify4.log: 57 tests, 0 failed checks (auth_core_test), 17 tests, 0 failed checks (tls_ca_test). Standalone host runs under the 4G cap before the gates: host7.log, tls7.log.

Mutation proof (host; each restored afterwards; logs mut3-*.run.log)

Mutant Failing test
other 4xx treated as transient session_a_missing_rpc_or_rejected_payload_holds_the_login_after_one_attempt_and_recovers
recovery loop removed session_an_outage_longer_than_the_backoff_ends_failed_then_recovers_with_the_cached_login (aborts)
bare 401/403 at boot prompts session_a_bare_403_at_boot_does_not_prompt_or_overwrite_the_cache
5xx while polling ends the login session_an_outage_while_polling_is_waited_out_until_the_codes_own_deadline
Denied by substring over the body refresh_denied_needs_the_body_to_name_the_refresh_token_a_bare_401_is_not_that (echoed body)
per-file size checked after reading a_huge_file_is_rejected_from_its_size_without_being_read
first corrupt PEM block stops the loop a_corrupt_pem_block_does_not_hide_the_blocks_around_it
blocking open entries_that_are_not_regular_files_are_skipped_counted_and_never_block (30 s harness timeout)
Token() not noexcept static_assert in the_calls_a_hook_will_make_into_questtokenauth_cannot_throw (build fails)
Earlier rounds' mutants (CAPATH, progress callback, Stop paths, link file, uid, retry bound, ...) are in the commit history of this PR; those logs are mut2-/mut3-R.

The sensor for linking token auth into the sentinel is only exercised on its not-linked path by just verify; a replay of its condition against scratch files (sensor-demo.sh) rejects "linked, flag missing" and "linked, allowlist test missing".

OpenSSL in the sentinel (ADR 0003)

Token auth is not linked into the sentinel. Linking it would bring OpenSSL/libcurl (about 35 MB unstripped vs 1.8 MB). The game's libraries export about 2411 OpenSSL/curl symbols (OpenSSL 3.0.0-dev, libcurl 7.68.0) and have the sentinel as DT_NEEDED, so the sentinel keeps --exclude-libs,ALL and TestExportAllowlist; just verify fails if the sentinel links nevr_quest_token_auth without them. The "keeps calls from binding to the game's copy" effect is an inference from a probe, not run on a device.

Not verified

  • Anything on a headset: the CA directories, the libcurl/OpenSSL stack on Quest, that the process name equals the package name, write access to /data/user/<n>/<package>/files, Quest multi-user, Bionic behaviour. auth_core_test is compiled for arm64, not run there.
  • TLS handling is tested against host libcurl 8.22.0 / OpenSSL 3.6.4, not the NDK build.
  • A std::thread constructor failure in Start() (caught; not testable here).
  • How the player sees the login link (device_login.txt is a placeholder) and the hook that hands the token to the login path.
  • ./launch-client.sh was not run.

Process note: in this round a mutation script restored a file with git checkout and discarded two uncommitted source files; I re-applied them and committed before mutating again. The pushed history is unaffected.

Update: delta review and the login file (tip 58a1acd8c31ee4048d1ed4ed1df7c71650c1abea)

Host tests only on this tip (auth_core_test 69 tests, tls_ca_test 20 tests, 0 failed checks, under a 4G cap; the Android sources also pass clang++ --target=aarch64-linux-android26 -fsyntax-only -Werror). just verify / just build-android / just test-android were last run on caba8cb, not on this tip.

  • Login file /sdcard/Android/data/com.readyatdawn.r15/files/device_login.txt, four lines: URL: <verification page>, Code: <code>, one instruction line that also carries the direct link with the code filled in, Expires: <UTC ISO time> (unix <seconds>). Written atomically for every new code (a stale file is replaced and is also cleared when the worker starts), removed when the login ends. One Info line says it was written and where (no code). If the directory is not writable: an Error, then an Info line with the direct link; the login continues.
  • A 200 device-code response without a usable code is transient (retry on the backoff, then recovery), not final.
  • A corrupt or unterminated PEM block no longer hides the good certificate after it.
  • A 200 poll body that cannot be read (captive portal) is waited out until the code deadline; the server's own {"error":..} is still final. The shared parser, and so the Windows module, is unchanged.
  • QuestTokenAuth::Create is a noexcept factory returning nullptr with a logged reason; the worker's failure report, ~Session and a failing join() are guarded.
  • SystemClock waits on a CLOCK_MONOTONIC pthread condition variable; SteadyNow reads CLOCK_BOOTTIME so a headset sleep ends the old device-code poll instead of polling a dead code.
  • Held 4xx refreshes in the background follow the 5-minute recovery cadence with one Warning per class; the OpenSSL-in-sentinel guard is now a CMake link-graph tool plus a build.ninja link-line test (TestSentinelLinkLineGuard).
  • Correction: a held 4xx logs the HTTP status on the core's "token refresh rejected" Warning line; the Error line that follows carries only the outcome name.

🤖 Generated with Claude Code

…allback thunks (refs #158)

WIP: tests and just recipes follow in the next commits.

Co-Authored-By: Andrew Bates <a@sprock.io>
Token model, refresh request/response handling and the device-code loop move to src/core with injected HTTP, clock and log, so the Quest shim can compile the same source. The Windows module delegates to them; the new core has not been built or tested yet.

Co-Authored-By: Andrew Bates <a@sprock.io>
…158)

Real shared objects (JUMP_SLOT, GLOB_DAT, RELRO, writable GOT, lazy) and
in-memory images (ambiguous, addend, malformed, vaddr-relative pointers,
read-only slot page) through the production Install/Remove.

Co-Authored-By: Andrew Bates <a@sprock.io>
…er OpenSSL on arm64-android (refs #158)

Session runs the cached-login refresh, the device-code login and the background refresh on its own worker thread, so Start() never blocks the game's bootstrap thread. Adapters: libcurl with certificate and host verification on and the Android system CA directory, an atomic 0600 credential file under /sdcard/Android/data/com.readyatdawn.r15/files/, and a login-link file. Host tests run it under a fake HTTP server and fake clock (just test-quest-shared). Not yet wired into the sentinel (ADR 0003 gates the hook).

Co-Authored-By: Andrew Bates <a@sprock.io>
…s into verify (refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
…refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
…ption contract, JSON logs (refs #158)

Review of #222: one lock across WriteSlot (two handles on one RELRO page
crashed), Remove and Install are compare-and-swap, protection to restore is
read under the lock, thunk lets the original's exceptions through and never
re-calls the original, atomic publish, fixed-size registry and pthread mutex
(no throw from the ELF constructor), GotHook::Forget, JSON log lines, no
agent paths in the repo test.

Co-Authored-By: Andrew Bates <a@sprock.io>
… a wrong shape, tidy comments (refs #158)

- RefreshOutcome::Denied for HTTP 400/401/403 so callers can stop retrying a token the server refuses; other non-200 stays Rejected.
- The wider json::exception catch now logs 'valid JSON of an unexpected shape' for [] or {"access_token":1} instead of 'not valid JSON'. gameserver.cpp's RefreshAuthToken call picks up the same catch.
- HttpClient::Interrupt() so a shutdown can abort a request in flight.
- The device-flow log says open_result= (it was shellexecute_result= on every platform).
- RefreshRequestBody test now calls BuildRefreshBody; comments describe the current tree; the refresh lead / fallback relationship is stated once and matches the static_assert.

Co-Authored-By: Andrew Bates <a@sprock.io>
… handle dying credentials (refs #158)

- CA: read the Android CA directories into memory and pass them as CAINFO_BLOB; CAPATH cannot find Android's old-hash file names with an OpenSSL backend. Fails closed, loudly, with no certificate. Proxy variables ignored, response size capped, every setopt checked, requests interruptible. Loopback TLS tests with a generated CA and an Android-style directory.
- The refresh token goes to /data/data/<package>/files (derived from /proc/self/cmdline); only the login link stays on /sdcard. O_NOFOLLOW, exclusive temp file, directory fsync.
- A permanent poll error gives up after a bounded run of failures and logs the status. A denied or expired refresh token publishes Expired and restarts the device login.
- Session: Start/Stop serialised, no lock held across the log sink, Start does not throw, Stop does not wait out a request. Tests run under a per-test deadline.
- N94 sensor pins the core-to-EchoVR log level mapping.

Co-Authored-By: Andrew Bates <a@sprock.io>
…haviour (refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
thesprockee added a commit that referenced this pull request Oct 8, 2026
…hared loader, not CAPATH (refs #158)

Token auth (#223) found OpenSSL cannot use Android's /system/etc/security/cacerts
as a CApath (old MD5 subject-hash names). CurlWsConnector now loads the same
directories with quest/auth/ca_bundle into memory and passes CURLOPT_CAINFO_BLOB;
with no certificate loaded every Connect fails closed (TlsError,
kNoTrustAnchors). The host TLS test trusts directories through the same loader;
the verify sensor requires CAINFO_BLOB and forbids CAPATH.

Not built or run: the host is under the disk floor.

Co-Authored-By: Andrew Bates <a@sprock.io>
…xports, one hook library (refs #158)

Second review of #222: handler that swallows the original's exception and
then throws is a handler fault (exception identity, not a sticky flag); the
entry uses one captured original per call and the rollback after a failed
re-protect keeps the original published; per-thread state moves from
emulated thread_local (allocates on first use) to pthread keys; streaming
relocation scan (no 8-slot cap); protect seam + tests for failed re-protect,
compare-and-swap Install and the 64-slot bound; -fvisibility=hidden with an
export allowlist test; got_hook compiled once into nevr_quest_got_hook with a
justfile sensor; first-call-only log in the clock hook.

Not built or run on this commit: the disk was under the 8 GiB floor.

Co-Authored-By: Andrew Bates <a@sprock.io>
- Denied needs the 401/403/400 body to name the refresh token; a bare 401 (nakama's wrong-http_key answer) is Unauthorized and does not drop the login.
- CA store: every certificate is parsed with OpenSSL and re-encoded, unparsable files are counted and skipped, a directory with nothing parsable falls through to the next, total size bounded.
- Per-user credential directory /data/user/<uid/100000>/<package>/files.
- Transient login failures (cached refresh, device-code request) retry on a bounded backoff; 4xx never retried; a flaky network does not prompt the player.
- CURLOPT_QUICK_EXIT so a shutdown does not wait for a resolver thread; real-curl test that interrupts a stalled request; configure-time curl >= 7.87 check; OpenSSL linked for the CA parse.
- Session: Stop() from the worker thread (a log sink) no longer deadlocks or joins itself, Start catches std::exception, the login-link file is removed when the flow throws, the bundle is shared by pointer.
- Docs: header and ADR state the current tree.

Co-Authored-By: Andrew Bates <a@sprock.io>
… thread (refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
…aths; ELF sensors (refs #158)

Third review of #222. The game uses libc++_shared's libgcc-style unwinder and
the sentinel a static LLVM one, so no exception may unwind through a sentinel
frame that has a personality. callback_thunk.h is now built -fno-exceptions
(enforced by #error), handlers are noexcept, the proxy / pthread-key /
exception_ptr machinery is gone. A kWriteVerifyFailed or rollback keeps the
original published, a failed re-protect no longer hides an earlier failure,
and a failed rollback compare-and-swap is logged. tests/quest pins the hook
frames to the personality-free CIE, rejects any non-libc++abi emulated TLS,
and counts the backend's compile rules in build.ninja; test-android runs with
-count=1 (Go cached a result across a changed .so).

Co-Authored-By: Andrew Bates <a@sprock.io>
…nk handler (refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
…ned slots, counter reporter (refs #158)

Fourth review of #222. The frame sensor now starts from every thunk entry and
every NEVR_HOOK_HANDLER function (own output section) and follows direct
edges, so a handler that passes the original to an exception-enabled helper
fails it. A slot where our entry may still be reachable through a foreign hook
stays reserved (retry would publish that hook as the original and loop).
Hooks only count; a reporter thread logs the counters (first change, then at
most once a minute). Thunk contract text states what noexcept does and does not
do under -fno-exceptions. The backend library is built -fno-exceptions like the
host tests. Host tests also parse every log line with nlohmann::json.

Co-Authored-By: Andrew Bates <a@sprock.io>
…isoned-slot status (refs #158)

Fifth review of #222. Hooks are defined by NEVR_HOOK_RECORD ({entry, handler} in
nevr_hook_records) and Arm takes only a record; the frame sensor requires one
record per thunk Entry and walks from each record's entry and handler, with no
allowlist. GotHook::Install is private; production installs through
InstallThunk (hook_install.h) and only GotHookTestAccess (tests) reaches the
raw install. The reporter no longer stays in fast mode because a counter never
moved: grace window, one never_fired line per idle counter, then one pass per
steady interval; reporter_started line, thread name, spurious-wakeup loop,
logging outside the lock, pthread_atfork. A retry on a poisoned slot reports
slot_poisoned and ReleasePoisonedSlotsIn only releases unmapped addresses.
hook_log.h states its real reasons and that logcat is not a durable log.

Co-Authored-By: Andrew Bates <a@sprock.io>
…fail-closed maps check (refs #158)

Sixth check of #222. HookRecord has no public constructor and Thunk::Arm refuses a
record outside the nevr_hook_records section; InstallThunk and ThunkInstaller
static_assert a CallbackThunk instantiation; just test-quest-hooks compiles
snippets that break each rule and requires the naming error. The raw-install
scan covers every C++ extension and production includes of anything under
src/quest/tests. IsMapped treats a failed open, a read error and an empty file as
unknown and ReleasePoisonedSlotsIn then keeps the reservation (logged).
StartReporter/StopReporter/Register share one control lock; never_fired text
corrected and fault-only counters never report it. Doc fixes (example, narrative,
logging rationale, advisory #error).

Co-Authored-By: Andrew Bates <a@sprock.io>
…refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
…e-fail and include gates (refs #158, #231)

- TestBackendBuiltWithoutExceptions matches the hook backend's own symbols by exact mangled
  prefix; the substring 8LogEvent also matched an exception-enabled function that takes a
  std::vector<LogEvent> (#220's ApplyEmbedded). Shown on a scratch merge with #220.
- The reporter holds 32 counters (static storage); the 33rd and any registration after
  StartReporter are refused and logged; the ADR states that registration precedes start.
- Compile-fail snippets must produce ONLY the rule's own errors, each matched against a
  rule-specific diagnostic.
- TestRawInstallOnlyInTests walks src/quest/tests recursively and resolves includes relative to
  the including file, src/ and src/quest/sentinel before the prefix test.
- One relroReadOnly comment, in the right place; ADR wording matches the scan; the stale
  docs citation in the Go test header and src/quest/CMakeLists.txt points at the real design doc.

Co-Authored-By: Andrew Bates <a@sprock.io>
… (refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
- Other 4xx than a refused refresh token (a bare 401/403, 400 missing payload, 404) are one attempt: cache kept, player not prompted, Failed until a recovery attempt. Denied now needs the JSON message to be one of the refresh RPC's exact errors, so an echoed or proxy body is not read as a refused token.
- A Failed login that is not final (outage longer than the backoff, held 4xx, refused device-code request) is attempted again every five minutes with one request, one Warning per failure class. A device login the player was involved in (expired code, timeout, refused poll) stays final.
- While the player holds a link, transient poll failures are waited out until the code's deadline; a 4xx poll ends the login.
- CA store: size and file type are checked on the open file before reading, each PEM block is parsed on its own, non-regular entries are counted as skipped.
- QuestTokenAuth's public calls are noexcept and catch std::exception; the worker thread is named nevr-auth; the response sink cannot throw through libcurl.
- Merge of origin/claude-quest-hooks was committed separately. justfile: sensor for OpenSSL/libcurl in the sentinel; ADR records the symbol-clash risk and the failure policy.

Co-Authored-By: Andrew Bates <a@sprock.io>
… not a grep (refs #158)

tools/verify_quest_sentinel_link.py walks the Quest CMake target graph from the sentinel (comments ignored, .cmake files included) and, if token auth, libcurl or OpenSSL is reachable, requires --exclude-libs,ALL on the sentinel and a live TestExportAllowlist. TestSentinelLinkLineGuard judges the real link statement in build.ninja. Both are falsified on the review's cases (intermediate target, parent file, comment-only flag, skipped/renamed/missing allowlist test, libcurl directly).

Co-Authored-By: Andrew Bates <a@sprock.io>
… (refs #158)

A 200 whose body has no string "code" (a captive portal, {"status":"ok"}, {"code":""}) showed the player nothing, so it retries on the backoff and then recovers like any other unreadable response, instead of ending the login as final.

Co-Authored-By: Andrew Bates <a@sprock.io>
… after it (refs #158)

An unterminated BEGIN ends where the next BEGIN starts and is counted unparsable; PEM objects that are not certificates (TRUSTED CERTIFICATE, CRL) are counted instead of vanishing.

Co-Authored-By: Andrew Bates <a@sprock.io>
…eshes follow the recovery cadence, recovery prompts log at Info (refs #158)

- A 200 poll response the parser cannot read (captive portal) is treated like an outage until the code's deadline; only an object with an "error" member is the server's own refusal. The shared parser is unchanged, so the Windows module still aborts on its first poll error.
- A refresh refused for a reason that is not about the token (Unauthorized/ClientError) is retried at the recovery period in the background loop too, with one Warning per class; repeats of the core's own lines drop to Debug.
- A device login that starts from recovery logs its state transition at Info.

Co-Authored-By: Andrew Bates <a@sprock.io>
…-> awaiting_user line (refs #158)

Co-Authored-By: Andrew Bates <a@sprock.io>
…(refs #158)

SystemClock::SleepFor waits on a pthread condition variable whose clock is CLOCK_MONOTONIC (libc++ on Android API 26 would use a CLOCK_REALTIME deadline for std::condition_variable::wait_for); SteadyNow reads CLOCK_BOOTTIME so the device-code deadline keeps counting while the headset sleeps and a dead code is not polled after wake-up (the login ends, the player is not re-prompted automatically).

Co-Authored-By: Andrew Bates <a@sprock.io>
…rding (refs #158)

Recovery with no cache -> device login, a refused re-login request -> recovery, recovery meeting a refused refresh token -> prompt. The 4xx device-request test now asserts what it means (no backoff, one attempt per recovery period); transient lists name 408 everywhere; FakeStore::Last no longer aborts the run on an empty list.

Co-Authored-By: Andrew Bates <a@sprock.io>
…aining terminate paths are guarded (refs #158)

QuestTokenAuth::Create returns nullptr with a logged reason instead of throwing from the caller's frames; the worker's failure report, ~Session and a failing join() can no longer throw out of a noexcept boundary or leave a joinable std::thread to terminate the process.

Co-Authored-By: Andrew Bates <a@sprock.io>
…ons, expiry (refs #158)

The link file now carries the verification URL, the code, one line of instructions with the direct link, and the expiry; it is replaced atomically for every new code, a stale file from an earlier session is cleared when the worker starts, and it is removed when the login ends. One Info line says it was written and where (no code). If the directory is not writable an Error says so and an Info line carries the direct link, and the login goes on.

Co-Authored-By: Andrew Bates <a@sprock.io>
It has a shebang and was committed 100644; test_no_new_non_executable_shebang_script fails on that.

Co-Authored-By: Andrew Bates <a@sprock.io>
@thesprockee

Copy link
Copy Markdown
Member Author

Landed via #326.

@thesprockee
thesprockee deleted the claude-quest-tokenauth branch October 10, 2026 08:08
@thesprockee thesprockee mentioned this pull request Oct 10, 2026
1 of 11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant