Repository navigation
Session Framework Design #7
Replies: 1 comment
|
Fundamental flaw: this plan assumes pyrois must use hobby. The entire design is predicated on hobby's synchronous middleware being an immovable constraint. It isn't. Pyrois is the framework. It doesn't have to use hobby's middleware system, and it doesn't have to sit on top of hobby at all. The ecosystem libraries (hobby, stallion, livery, mare) are available pieces, not mandatory dependencies. Pyrois chooses its own architecture. Every design decision in this plan flows from that wrong assumption: cookie-based sessions instead of an actor store, handler-driven response helpers instead of middleware-managed cookies, no Closing this and starting over with the right framing: the session design should drive the architectural choices, not the other way around. |
Uh oh!
There was an error while loading. Please reload this page.
Session Framework Design
This is item 2 from the pyrois build plan: session ID generation, session storage, and middleware that loads and writes sessions on every request. The original plan called for an in-memory storage actor. That turns out to be architecturally impossible with hobby's current middleware model. This document explains why and lays out the design that works.
The constraint that shapes everything
Hobby's
Middlewareinterface isvaland runs synchronously._ChainRunnerexecutes the entirebefore→ handler →afterpipeline within a single behavior on the_Connectionactor. There are no yield points. No callbacks. No awaiting.This has two consequences that matter:
Middleware can't query actors. An actor is
tagfrom outside. You can send it messages, but you can't read its state. Getting data back requires a callback (a behavior or a promise), which requires yielding control and resuming later. Hobby's middleware doesn't support that. There is no way to send a message to a session store actor inbeforeand get the session data back before the handler runs.aftercan't modify the response. By the timeafterfires,ctx.respond()has already been called and the bytes are on the wire. Therespondandrespond_with_headersmethods on Context check_handledand silently no-op if a response was already sent. There's no mechanism to inject aSet-Cookieheader after the fact.These constraints rule out the traditional session middleware pattern where
beforeloads the session from a server-side store andafterwrites the session cookie. Both halves are broken.A tracking issue should be filed on hobby for async request processing support (something like a pre-middleware phase that can do actor queries before the synchronous chain starts). That's the path to server-side session storage. It's out of scope here.
Cookie-based sessions
Session data lives entirely in the cookie, signed with HMAC-SHA256 via the existing
SignedCookieprimitive. No server-side state. No actor coordination. Fully synchronous.This is the same default approach Phoenix uses (
Plug.Session.COOKIE). Flask does the same thing. Rails has a cookie store. It's a well-understood pattern that handles the common case: small payloads like user IDs, CSRF tokens, and flash messages.The session data is signed but not encrypted. Anyone with browser dev tools can read the cookie value. The HMAC signature prevents tampering but not reading. Don't store secrets in the session. Store identifiers and look up sensitive data server-side. Encryption can be layered in later by adding an encrypt-then-sign step to the codec, but the initial version doesn't need it and the complexity isn't justified yet.
The tradeoff: session data is bounded by the browser's cookie size limit (~4KB). After signature overhead (45 bytes for the
.separator and Base64-encoded HMAC), roughly 4000 bytes are available for serialized session data. That's plenty for typical session payloads. If you're trying to stuff a shopping cart into a cookie, you've made a wrong turn somewhere.The response problem
The middleware loads the session in
before. The handler reads and modifies the session. The modified session needs to get back into aSet-Cookieheader on the response. Butaftercan't touch the response. Andbeforeruns before the handler has done anything.The solution: handlers that modify session data respond through a session-aware helper instead of calling
ctx.respond()directly.SessionResponse.respondserializes the session, signs it, builds theSet-Cookieheader, and callsctx.respond_with_headers(). The handler changes one call. Handlers that only read the session (the common case) keep usingctx.respond()as usual since the browser already has the cookie from the previous request.This is explicit about what's happening. No magic cookie injection. The handler opts into session persistence by choosing the response function. Hobby's "first response wins" model applies: if the handler calls
ctx.respond()beforeSessionResponse.respond(), the session cookie isn't set.Types and API
Session(class val)Immutable session data container. Every mutation returns a new
Session val. This is forced by hobby's context storage (Any val), but it's also the right design for Pony: functional updates on avaltype, no shared mutable state, no data races by construction.with_valueandwithoutbuild a newMapinside arecover valblock, copying from_data(which isvaland therefore sendable into the recover block), applying the modification, and lifting toval. For typical session sizes (under 20 entries) the copy cost is negligible.with_idexists for session fixation prevention. When a user authenticates, the auth handler should issue a fresh session ID while preserving the session data:session.with_id(SessionId.generate()?). This prevents an attacker who set a crafted cookie before login from hijacking the authenticated session.The session ID is a separate field, not a map entry. Handlers can't accidentally overwrite it via
with_value("_id", ...)because it's not in the map. Theid()accessor returns it explicitly.SessionId(primitive)32 bytes from
crypto.RandBytes, Base64-URL encoded with padding (pad = true), producing a 44-character string. 256 bits of entropy.Why
pad = true: the stdlib'sBase64.encode_urlwithpad = falseemits null bytes (0x00) instead of stripping the padding characters. This was discovered during the signed cookies work and the workaround is consistent.=(0x3D) is a valid cookie-octet, so padded output works.If
RandBytesfails,generateerrors. The middleware propagates this and hobby sends a 500. A CSPRNG failure means something is fundamentally wrong with the runtime environment. Degrading gracefully by issuing a session with a predictable or empty ID is worse than a 500 because the downstream auth machinery would treat it as a valid session.SessionConfig(class val)The constructor is partial because it validates the cookie name at construction time. It does this by building a test
SetCookieBuilder(cookie_name', "x").build()— the"x"is a known-valid dummy value so any error is unambiguously about the name, not the value. If the name isn't a valid RFC 2616 token, construction fails at startup instead of on the first request. Fail fast.Security attributes are not configurable.
SetCookieBuilderdefaults toSecure=true,HttpOnly=true,SameSite=Lax. These are the correct settings for session cookies:Secure=falsesends the cookie over HTTP, exposing it to network sniffing.HttpOnly=falsemakes the cookie readable by JavaScript, enabling XSS-based session theft.SameSite=Noneallows cross-site request attachment, undermining CSRF protection.Per "it is easier to give than take away": if someone has a legitimate need to weaken these (local development without TLS), we can add the knobs later. Shipping with them configurable invites misconfiguration.
SessionMiddleware(class val, is hobby.Middleware)beforebehavior:ctx.request.cookies.get(config.cookie_name).Session valin context.SignedCookie.verify(). If verification fails (tampered, malformed, wrong key), treat as "no cookie" — generate fresh session. This prevents session fixation: an attacker who sets a crafted cookie gets an empty session with a new ID, not an error._SessionCodec.decode(). If deserialization fails (corrupt data), treat as "no cookie."Session valin context under"_pyrois.session". Store theSessionConfigunder"_pyrois.session_config".afterdoes nothing. Session persistence is the handler's responsibility viaSessionResponse.The context keys are defined as constants on a package-private
_ContextKeysprimitive shared by the middleware, accessor, and response helper. This localizes the coupling: if someone changes a key string, all three consumers use the same definition.SessionResponse(primitive)respondandrespond_with_headersserialize the session, sign it, build theSet-Cookieheader viaSetCookieBuilder, and callctx.respond_with_headers(). Therespond_with_headersvariant merges the session cookie header with caller-provided headers.deletesends a response withMax-Age=0to instruct the browser to remove the session cookie. This is the logout path.All three functions compute
Content-Lengthfrom the body and include it in the headers. This is required because hobby'sctx.respond_with_headers()does not setContent-Lengthautomatically (unlikectx.respond()which does). The body size is computed viamatch \exhaustive\ body | let s: String val => s.size() | let a: Array[U8] val => a.size() end, the same pattern hobby uses internally.The config is retrieved from context (stored by the middleware in
before). If the middleware hasn't run, these functions fall back to a plainctx.respond()without a session cookie. This makes them safe to use during development and testing even if the middleware isn't wired up yet.Max-Ageon the cookie is set fromconfig.max_agevia a conditional call toSetCookieBuilder.with_max_age():SetCookieBuilder.with_max_agetakesI64, not(I64 | None), so the conditional is necessary.SessionData(primitive) — typed context accessorTyped accessor for reading session data from hobby's context. Takes
Context box(read-only access). Errors if the middleware hasn't stored a session._SessionCodec(primitive, package-private)Serialization format: URL-encoded key-value pairs, sorted by key.
The session ID is included as the
_idkey in the serialized data. This means the ID is part of the signed payload, preventing an attacker from swapping cookie bodies between sessions.Keys are sorted lexicographically before encoding. This makes serialization deterministic: the same session data always produces the same serialized string, which produces the same HMAC signature, which avoids unnecessary cookie churn.
Keys and values are percent-encoded: everything outside the unreserved set (
[A-Za-z0-9_.~-]) is encoded as%XX. The=delimiter between key and value and the&delimiter between pairs are encoded if they appear in the data itself. This is standardapplication/x-www-form-urlencodedencoding with a minimal charset.The codec is ~40 lines. No dependency on
ponylang/uri. The percent-encoding rules are simple enough that a purpose-built implementation is cleaner than pulling in a library designed for URI components.The full cookie value is produced by passing the encoded string through
SignedCookie.sign(config.key, encoded), which appends.base64url(hmac). The existingSignedCookie.verify()usesrfind(".")to find the last dot, so dots in the encoded payload (which can't happen with the percent-encoding scheme anyway) would not cause issues.Expiry
Cookie
Max-Ageonly. No_tsfield in the session data.The browser enforces
Max-Age. When the cookie expires, the browser stops sending it, and the middleware creates a fresh session on the next request. No server-side timestamp checking needed.Storing a creation timestamp and comparing against wall clock time in
beforehas real failure modes. NTP clock jumps can invalidate sessions unexpectedly. The server has to distinguish "creation time" from "last seen time" from "current time" and decide what counts as expired. It's complexity without corresponding value.If server-side session invalidation becomes necessary (for "log out all devices" scenarios), that's the moment to introduce server-side storage. Don't anticipate it with a timestamp mechanism that doesn't actually provide revocation.
Security properties
Session ID entropy. 256 bits from OpenSSL's CSPRNG. At a billion guesses per second, the expected time to find a valid session ID is longer than the expected lifetime of the universe.
Cookie integrity. HMAC-SHA256 via the existing
SignedCookieprimitive, which usesConstantTimeCompareon the raw 32-byte HMAC. The comparison is always exactly 32 bytes (legitimate HMACs always decode to that length), so the early-return on length mismatch inConstantTimeComparedoesn't leak timing information about valid signatures.Session fixation prevention. Invalid or missing cookies always create fresh sessions with new IDs. An attacker who pre-sets a cookie value gets nothing because it fails signature verification. Auth handlers should also call
session.with_id(SessionId.generate()?)after authentication state changes (login, privilege escalation) to rotate the session ID while preserving data.Cookie transport security.
Secure,HttpOnly,SameSite=Laxare always on.Secureprevents the cookie from being sent over HTTP.HttpOnlyprevents JavaScript from reading it (XSS mitigation).SameSite=Laxprevents the cookie from being sent on cross-site POST requests (CSRF baseline).Confidentiality. Session data is signed, not encrypted. The cookie value is human-readable. Store identifiers, not secrets. This is documented prominently on the
Sessionclass and in the package docstring.Key management.
CookieSigningKey.generate()creates a random key at startup, suitable for development. In production, the key must be persisted across restarts (environment variable, secret store, etc.) or all sessions are invalidated on every deploy.CookieSigningKey.create(key_from_env)?is the production path. The example and package docstring make this explicit.Key rotation.
SessionConfigholds a singleCookieSigningKey. Key rotation is manual for v1: deploy a new key, all existing sessions become invalid. The architecture supports adding aCookieSigningKeyRinglater (primary key for signing, retired keys for verification fallback) without changing the session API.Concurrent requests. Two concurrent requests from the same user each carry their own copy of the session data in the cookie. If both handlers modify the session, the last
Set-Cookieto reach the browser wins. This is inherent to cookie-based sessions, not a bug. It's the same behavior as every other cookie session implementation.Cookie size. No runtime enforcement for v1. The browser enforces the ~4KB limit. If the serialized session exceeds this, the browser silently drops the cookie. This is documented. For typical session data (session ID, CSRF token, user ID, a few flags), the payload is well under 500 bytes.
Downstream integration points
The session framework is the foundation for the next items in the build ordering. The design anticipates their needs:
LiveView session bridge (item 3). The session ID is available via
session.id(). The bridge embeds this in the SSR page as a data attribute. On WebSocket upgrade,mare.UpgradeRequest.header("cookie")provides the session cookie, which is verified and deserialized the same waybeforedoes it. The bridge code can reuse_SessionCodecandSignedCookie.verify()directly.CSRF protection (item 4). The CSRF token lives in the session under the key
"_csrf_token". CSRF middleware generates the token if absent (session.with_value("_csrf_token", token)), embeds it in forms, and validates it on state-changing requests. The session framework doesn't need to know about CSRF — it's just another key-value pair.Auth middleware (item 6). Login stores
"user_id"in the session and rotates the ID:session.with_value("user_id", uid).with_id(SessionId.generate()?). Logout clears the session:SessionResponse.delete(ctx, status, body). Therequire-authmiddleware readssession.apply("user_id")?and redirects if it errors.Dependencies
Update
corral.jsonto add hobby:{ "locator": "github.com/ponylang/hobby.git", "version": "0.3.0" }Hobby 0.3.0 brings stallion 0.5.1 transitively. Pyrois uses stallion types in its public API (
stallion.Status,stallion.Headers,SetCookieBuilder), so we should verify whether corral resolves the transitive dependency or if stallion needs to be listed explicitly.The existing
ssldependency remains for direct crypto access.File organization
All session code lives in the
pyroispackage. No subpackage. There isn't enough code to justify the split, anduse "pyrois/session"alongsideuse "pyrois"adds friction for the common case where you want both.Build order
Step 0: Hobby Context testability (resolved).
Context's constructor takes_Connection tag, which is package-private._Connectioncannot be named outside the hobby package, soContextcannot be constructed in pyrois tests. The test strategy for middleware integration goes through extracted pure functions (_SessionLoader) rather than constructing a Context directly.Step 1:
_PercentEncode,_PercentDecode,_SessionCodec. The serialization foundation. No external dependencies beyond stdlib. Tests: round-trip, empty session, special characters, malformed input, deterministic output.Step 2:
SessionId. Depends onssl/crypto(already a dependency). Tests: length, character set, uniqueness, Base64 decodability.Step 3:
Session. Depends oncollections.Map. Tests: get/contains, with_value/without/with_id, immutability verification, pairs/size.Step 4:
SessionConfig. Depends onCookieSigningKeyand stallionSetCookieBuilder(for name validation). Updatecorral.jsonwith hobby dependency. Tests: defaults, custom values, invalid cookie name.Step 5:
SessionMiddleware,SessionData,_ContextKeys. The middleware integration. Core logic extracted into_SessionLoader(a testable function that takes a raw cookie string, not a Context) per step 0 findings.Step 6:
SessionResponse. Response helpers. Tests for Set-Cookie header construction, delete behavior.Step 7: Package docstring, example,
examples/README.md. The example is a hobby application with a visit counter and logout. ShowsSessionConfig,SessionMiddleware,SessionData.from_ctx(),SessionResponse.respond(),SessionResponse.delete().Step 8: File a tracking issue on hobby for async request processing support to enable server-side session stores.
Testing strategy
Tests follow existing pyrois patterns:
\nodoc\on all test declarations, private_Test*and_Prop*naming, PonyCheck for property-based tests, single test runner in_test.ponydelegating to per-file registration primitives.Build and run:
make testSession ID tests (
_test_session_id.pony)Property-based:
_PropSessionIdLength— every generated ID is exactly 44 characters (32 bytes → Base64 with padding)_PropSessionIdCookieOctet— every byte is in the Base64-URL alphabet plus=_PropSessionIdUnique— generate 50 IDs per sample, assert all distinctUnit:
_TestSessionIdVector— known 32 bytes → expected Base64 string (isolates encoding from RNG)_TestSessionIdDecodable— generated IDs successfully decode viaBase64.decode_urlSession type tests (
_test_session.pony)Property-based:
_PropSessionWithValueGetRoundTrip—session.with_value(k, v).apply(k)? == vfor arbitrary printable ASCII k/v_PropSessionWithValuePreservesOther— for distinct k1/k2,session.with_value(k1, v1).with_value(k2, v2).apply(k1)? == v1_PropSessionWithoutRemoves—session.with_value(k, v).without(k).contains(k) == false_PropSessionImmutability— afterlet s2 = s1.with_value(k, v),s1.contains(k) == falseands1 isnt s2Unit:
_TestSessionEmptyGet—apply("absent")errors_TestSessionGetOr—get_or("absent", "default")returns"default"_TestSessionWithValue— set then get returns value_TestSessionOverwrite— set same key twice, second value wins_TestSessionWithout— remove a key that exists_TestSessionWithoutMissing— remove a key that doesn't exist (no error)_TestSessionWithId— new ID, data preserved_TestSessionSize— size reflects number of entries_TestSessionContains— true for present keys, false for absentCodec tests (
_test_session_codec.pony)Property-based:
_PropCodecRoundTrip— for any generatedMap[String, String], build Session, encode, decode, all entries match_PropCodecDeterministic— same Session + same key → same encoded string_PropCodecTamperDetection— flip any single byte in a valid signed cookie, verification fails_PropCodecCookieOctet— serialized (pre-signing) output contains only valid cookie-octet charactersUnit:
_TestCodecEmptySession— empty session round-trips_TestCodecSingleKey— one key-value pair round-trips_TestCodecMultipleKeys— three keys, all preserved, order doesn't matter_TestCodecSpecialCharValues— values with=,&,%, spaces, unicode_TestCodecSpecialCharKeys— keys with special characters_TestCodecEmptyStrings— empty key and empty value both round-trip_TestCodecSortedOutput— output keys are alphabetically sorted (check raw encoded string)_TestCodecSignedRoundTrip— encode → sign → verify → decode → same data_TestCodecTamperedValue— modified signed cookie fails verification_TestCodecWrongKey— signed with key A, verify with key B fails_TestCodecMalformedPayload—decode("not valid")errorsMiddleware/loader tests (
_test_session_middleware.pony)Core logic extracted into a testable function:
This takes the raw cookie string, not a Context.
Property-based:
_PropLoaderRoundTrip— for any session, encode+sign thenload()recovers same data and ID_PropLoaderTamperDetection— flip any byte in a valid signed cookie,load()returns a new session with a different IDUnit:
_TestLoaderNoCookie—load(None, config)returns new session_TestLoaderValidCookie— valid signed cookie returns session with expected data_TestLoaderTamperedCookie— tampered cookie returns new session (not error)_TestLoaderWrongKey— signed with different key returns new session_TestLoaderMalformed—load("garbage", config)returns new session_TestLoaderEmptyString—load("", config)returns new sessionResponse helper tests (
_test_session_response.pony)These test the header construction logic extracted from
SessionResponse:_TestResponseSetCookie—SessionResponseproduces a response withSet-Cookieheader containing the signed session_TestResponseMaxAge— whenconfig.max_ageisI64,Max-Ageappears in the cookie_TestResponseNoMaxAge— whenconfig.max_ageisNone, noMax-Agein the cookie_TestResponseDelete—delete()producesSet-CookiewithMax-Age=0_TestResponseMergeHeaders—respond_with_headersincludes both session cookie and caller-provided headers_TestResponseContentLength— all response helpers include correctContent-LengthheaderTest totals
Counterfactual protocol: after writing each test, temporarily break the assertion to confirm it fires. For property tests, add
h.log()to verify generator coverage before concluding an assertion is weak. Run only the specific test during iterations. Full suite once at the end.Open questions
Hobby Context testability (resolved).
Contextcan't be constructed outside the hobby package (_Connectionis package-private). The middleware integration tests go through_SessionLoader(the extracted pure function). Step 0 confirmed this.Corral transitive dependencies. Pyrois depends on
hobbyandsslexplicitly. Hobby depends on stallion, which depends on ssl. Whether corral handles this diamond cleanly, or whether stallion needs its own explicit entry incorral.json, should be verified empirically.Persistent map iteration order. The codec sorts keys before encoding for determinism. If
Map.pairs()already iterates deterministically for structurally equal maps, the sort is redundant but harmless. If it doesn't (which is the safer assumption for a hash map), the sort is necessary. The property test_PropCodecDeterministicvalidates this.All reactions