auth: exchange a provider credential for a SpacetimeDB token (#31) #64
No reviewers
Labels
No labels
area:defold
area:docs
area:infra
area:protocol
area:testing
kind:bug
kind:chore
phase:breadth
phase:core
phase:hardening
phase:slice
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
yuris/spacetimedb-defold!64
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/31-auth-exchange"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #31. Phase 3 of the v1.1.0 milestone (#59) — the headline feature, and
the largest gap against the peer SDK. Purely additive: a new module plus one new
seam. No existing signature changes.
3a — the endpoint spike, settled: branch C
Run against the flake-pinned standalone 2.8.2 before writing any code. There is
no exchange endpoint, and no way to point the server at a third-party issuer.
spacetimedb-standalone start --helpexposes exactly--jwt-pub-key-pathand--jwt-priv-key-path— a single keypair, no issuer list, no JWKS URL. So aGoogle/Steam/Apple JWT cannot be made acceptable as
?token=by configuration onthis version, and branch A is out along with branch B.
Therefore the exchange target is the game's own backend, which verifies the
provider credential and obtains a SpacetimeDB identity — matching the peer SDK's
provider-agnostic posture. This changes nothing structurally:
opts.urlis aconfigurable URL, so if a future server version grows an endpoint, callers point
at it instead.
Incidental, recorded but deliberately not pursued: a malformed bearer token
panicked a tokio worker (
spacetimedb-jsonwebtoken-9.3.0/src/decoding.rs:199,"entered unreachable code") and took the server process down. Upstream behaviour
in a pinned dependency, out of scope here — but one more argument for branch C's
shape, since a third-party credential should never be presented at the
SpacetimeDB edge.
3b — the
httpseamstdb/init.luagainsM.http_seam(), placed immediately afterM.sys_storageand mirroring it exactly — a factory returning a table of closures, so tests
substitute a table and skip the engine. This is the SDK's first HTTP
(
grep -rn "http\." stdb/returned nothing before this), so the shape is sethere.
Callback contract:
(err, status, body).erris a string only for atransport failure; any HTTP status, 4xx and 5xx included, arrives as a status
with
err = nil.The
res.status == 0transport-failure convention was verified against enginesource, not assumed — and this is where it could have gone wrong:
engine/dlib/src/dlib/http/http_service.cpp:637-648— onRESULT_OKorRESULT_NOT_200_OKit sends the real status; otherwiseSendResponse(request, 0, ...).Invalid URL → 0 at
:536/:552; cannot-create-connection → 0 at:658.script_http.cpp:158passes it through untouched.test_http.lua.raw:79-87expectsstatus == 0for an unresolvable host while a real 404 asserts 404, andtest_http_timeout.lua.rawexpects 0 for every timeout.script_http_js.cppOnHttpError), so onedetection covers both targets.
httpdocs do not document this convention; source andthe engine's own tests are the authority.
Two traps that would have made a naive check wrong, both now recorded in the
seam's comment so nobody re-derives them:
res.responseis""on transport failure, notnil— never key off it.res.erroris not a transport indicator; it is set only to"Failed to write to temp file" for a failed download-to-disc.
3c —
stdb/auth.luaOne public function,
auth.exchange(opts, cb).cbfires exactly once.Each decision, with its reason:
Authorizationheader. The exchange target is not the resource server; a header would invite
a proxy to forward it as one. Body is
json.encode({ token = credential }),header is
Content-Type: application/json, and the tests assert there is noother header and no other field.
storagedefaults to nil — no caching. Deliberately unlikeconnect,which defaults to
sys_storage(). An identity token is the SDK's own topersist; a provider-exchanged token is the game's credential, and the SDK must
not silently write it to disk. The caller opts in.
providerstring. No JWT parsing anywhere— so no base64url decoder ships, and there is no path by which an unverified
claim could be mistaken for an authorization decision. Stored as
{ [provider] = token }, merged on save so a second provider cannot evictthe first.
and
connectalready handles a rejected token by reconnecting, so a stale entrysurfaces as a failure at
connect. Clearing the cache is the caller's move —documented loudly in the README. No expiry parsing, which would need the JWT
decode this scope excludes.
will not be accepted on the second ask, so retrying would mean three more
rejections and a 3.5s wait before the game learns the player is not signed in.
5xx and transport failures do retry.
connection.backoff_delay— four lines of itsown arithmetic. The issue is explicit that the exchange's retry must not be
entangled with the socket reconnect; sharing the function would make one knob
move both.
the request already answered — because the seam is free to call back
synchronously (the tests do), and a timeout armed first would fire before the
request was ever made. A per-attempt
settledflag discards a late answer; aseparate
finishedflag keepscbto one call.stdb/auth.luacontains noprintat all, and no response body is ever interpolated into a message — a body may
itself carry a token. Errors name only a status and a count.
url/provider/token/jsonraise at level 3, blamingexchange's caller, matching theerr()helpers incache/protocol/schema/tables. An empty-stringprovidercounts as missing:""would collapseevery provider into one cache entry.
pcalled so a bad body never escapes as a Lua error; to a caller both mean theendpoint returned no token. A non-string
tokenis also not a token.jsonis required, no default, matchingconnect.timeris defaulted at calltime rather than load time, because bare Lua has no
timerglobal and a modulethat errored on require would take the unit suite with it.
httpresolveslazily to avoid a circular require with
stdb/init.lua.3d — tests
New
dev/test/test_auth.lua, 377 lines; the runner discovers it by glob, so noregistration. Suites went 6 → 7, 0 failed. Everything runs with no engine and
no clock: the
httpandtimerseams drive retries, backoffs and timeoutssynchronously rather than sleeping.
Beyond the issue's acceptance list, the ones worth naming:
three retries means exactly three backoffs and no fourth;
timeout, … 4 timeouts + 3 backoffs = 7 entries;
the callback twice;
so the error names what actually happened last;
was no transport failure;
hidden backoff wait either;
The redaction test collects every string the SDK can produce — callback
errors, raised errors, anything printed — across every failure mode plus the
success paths, including response bodies that themselves carry the secrets, and
asserts none contains the credential or the token. It also guards against passing
vacuously: it asserts the sweep actually collected >8 composed strings, and that
the token really did reach the caller, so the check cannot be clean merely
because the module returned nothing.
Checks
Cache-hit check — zero requests on the second call:
Redaction and no-4xx-retry check:
Conformance was not re-run: this adds no wire behaviour to the socket path. The
unit suite is the proof.
Also in this branch
readme: consumer archive is 12 entries now, not 11—auth.luaships insidestdb/, so the README's measured archive figures went stale the moment thismodule landed (54→56 tracked, 11→12 shipped, both re-measured). Phase 5's archive
audit compares against those numbers, so correcting them here keeps that gate
honest.
a6e761a258ef50c913d6Review actioned — all six findings. Verdict: NEEDS ANOTHER REVIEW, because
B1's fix touches
stdb/init.luaand because my own verification then found asecond blocking defect that no unit test could have seen.
Thank you for B1 in particular. Constructing the real seam rather than trusting
fake_storageis exactly the move, and it found a defect that made thedocumented configuration silently useless.
B1 (BLOCKING) — fixed in
sys_storage, not by retreating from the docsYou were right that the suite structurally could not see this:
fake_storagereturns whatever was saved unconditionally, so it modelled a seam the SDK does
not ship. Through the documented
stdb.sys_storage("auth")every load missed —re-exchanging the player's credential on every launch — and the merge re-read
through the same filter, got nil, restarted from
{}, and destroyed the otherprovider. Both README claims were false exactly as written.
I took your first suggestion rather than the second.
loadis now keyed onemptiness (
sys.loadreturns{}for a missing file) instead of.token, whichmakes it shape-agnostic. Your own verification is what made it safe to do:
connectreads onlypersisted.token, where{}behaves exactly asnildid.Retreating to "stop documenting
sys_storagefor auth" would have left a seam inthe SDK that quietly breaks for every shape but one, which is worse.
Regression test added that exercises
sys_storagethrough a stubbedsys— theway
test_init.luastubs engine globals — includingsys.loadreturning{}fora missing file, so the specific trap is pinned.
B2 (BLOCKING) — found by my own conformance run: bob refused to build
auth.luadefaulted its http seam viarequire("stdb.init"), andinit.luarequires
stdb.auth. Lazy at runtime, but bob's dependency scan is static:Both harnesses failed at build. The seam now lives in
auth.lua, where its onlyconsumer is, and
init.luare-exports it asstdb.http_seam, so the documentedAPI is unchanged — one definition, one direction.
This is also a process finding against me: I told the implementer to skip
conformance because the change "adds no wire behaviour". Wrong. Adding a module to
stdb/changes what bob compiles, and the harness is the only thing that compilesit. Noting it because the same instruction would have hidden it again.
N1 — fixed. It was worse than vacuous; it was actively misleading.
next(t, "Content-Type")on a two-key table always returns nil, so the assertioncould never see a single extra header — and an added
["X-Provider-Credential"] = credentialpassed the suite for all seven names youtried. Counted now, and the body's field check with it. The PR body's claim that
"the tests assert there is no other header and no other field" was only half true,
as you said.
N2 — fixed, and this is the one I would have missed
Both guards were unpinned, and your diagnosis of why is the useful part: the
test delivered its late answer after the exchange had already settled, so it
exercised
finishedand neversettled. Rewritten with a hand-driven timer queueso the late answer arrives while the replacement attempt is still in flight —
the only window
settledcovers.N3 — fixed by pcall'ing the write, not by softening the doc
You framed the choice correctly and I took the first branch.
exchangepromisescbfires exactly once, so it should keep that promise rather than document itsway out. The token is already paid for by the time the cache is written, and a
failed cache write is the same outcome as passing no storage at all. The load on
the way in still raises synchronously, where a caller can
pcallit, matchingthe SDK's convention — the asymmetry is deliberate and now commented.
N4 — fixed
Type-checked on both sides. The
string-metatable path (a string store plus aprovider named
format, handing back a function) is a genuinely nasty one; itis now covered by a test.
N5 — fixed
Treated as a transport failure rather than compared, so a contract-breaking seam
cannot raise out of the http callback and lose the callback. Hardening, as you
said, but the failure mode was the unrecoverable one.
N6 — noted for the record
script_http.cppis nowengine/gamesys/src/gamesys/scripts/script_http.cpp;the line number and the claim were right and only the directory predated the
gamesys move. Corrected here rather than silently, since Phase 5 follows these
paths.
Mutation-tested, because your point 7 showed why that matters
Each fix was re-verified by breaking it: reverting the
sys_storagefilter,dropping the cached-hit type check, dropping either
cbguard, dropping thestatus guard, dropping the save
pcall, and adding a credential-carrying headerare each caught by the suite now. Before this commit five of those six were
green.
State
Rebased onto current
main(cf8b430). 7 suite files, 0 failed; stylua exit 0;native conformance PASS; HTML5 conformance PASS — the last two being what caught
B2.
Round two actioned. All four findings fixed. Thank you especially for re-running
the mutation table independently instead of taking my word for it — that is what
caught F1, and F1 is a claim I made.
F1 — fixed, and the record corrected
You are right on both counts. My previous commit said "dropping either
cbguard… [is] each caught" and "before this commit five of those six were green". The
measured position was five of six caught now, with two guards still unpinned. I
have corrected that in the commit message, since it becomes the squash-merge
record.
The substantive half is the timeout callback's
settledcheck, and yourreproduction is the part I had not thought about: with a real async seam — the
only kind that ships — the ordinary ordering is a response arriving while the
timeout armed alongside it is still queued. My async test only ever fired attempt
1's timeout before attempt 1 answered, so the mirror case was invisible:
Added the mirror case on the same hand-driven queue: answer with a 500, fire the
now-stale timeout, assert neither
http.callsnor the timer queue moves. Deletingthat guard now fails the suite — verified.
finishedstays unpinned, and you predicted exactly why: with the per-attemptsettledguards in place no secondfinishis reachable, so every mutation of italone leaves the suite green. I kept it rather than deleting it —
cbfiring twiceon a credential path is worth three lines against a future seam that calls back
more than once — and it is now commented as deliberately redundant and deliberately
untested. The old comment invited exactly the "no test covers this, remove it"
cleanup that would take the guard away.
F2 — fixed, in both places
The type check was sold as defending against a corrupted save file, and it does
not.
sys.loadraises on a truncated or version-mismatched file, that raisepropagates out of the deliberately unguarded load, and the exchange does not
proceed. Only a well-formed store with a non-string entry — or a caller's own seam
— reaches the check. As you put it, the old wording told a maintainer a corrupt
file was handled gracefully when it is a hard Lua error. Reworded in the README and
in the code comment, with the raise named and the
connectparallel stated.F3 — fixed
A permanently failing cache write being invisible is a real gap in the docs rather
than the code: the
pcallis right for the cb-once contract, but a read-only orfull save directory produces no error, no log and no return value, and the only
symptom is that "a cache hit makes zero requests" never happens. Now stated in
the cache section, in one sentence, as you suggested.
F4 — fixed
"Every attempt hit
timeout" is wrong for a mixed run; three transport failuresplus one silent attempt reports it too. It means the last attempt timed out, and
the table now says so.
On points 1 and 2, which were the reason for this round
Thank you for grepping every consumer of
sys_storageandstorage.loadratherthan accepting my reasoning about
connect. The widenedloadis the one changehere that touches a pre-existing frozen path, and it needed someone other than its
author to confirm nothing else keyed off the old filtering. Same for confirming the
http_seammove genuinely broke the cycle and thatstdb.http_seam()stillmatches what the API table documents.
State
7 suite files, 0 failed; stylua exit 0; native conformance PASS; HTML5 conformance
PASS.
e21dd5fb2b223cc0e25d