diff --git a/docs/00-status.md b/docs/00-status.md index 963cb40..a22a06e 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -34,6 +34,14 @@ crashed the release build; `copy_place_text` now sees through `Interp` exactly as `drop_fresh_text` does, pinned by `tests/corpus/run/interp-borrowed-field`. +**Auth-in-core landed 2026-08-20** (same branch): `http/auth.wo` — header +parsing, pure-`.wo` base64, constant-time `ct_eq`, `req.principal` as the +blessed principal slot (Middleware.before takes `mut req`), `BearerAuth` + +`BasicAuth` middlewares; policy stays app-side. Probe matrix 26/26 +ASan-clean; web-app dogfoods BearerAuth; `just web-app` **17/0**. The +framework README carries the core checklist (✅ / candidate / parked-by- +design rows). + Next per the implementation order (17 parked): finish the half-done database branches — 9c (ipc-attach: manifest + binding) and 9d (keypair-auth: manifest) — both need their forks brainstormed before @@ -151,7 +159,7 @@ that sequences its tasks. Read one, approve, then the next starts. | 13 | [Compile-time metaprogramming](stories/language-runtime-database/13-compile-time-metaprogramming.md) | ⬜ needs a spec first | | 14 | [skillhost host workload](stories/language-runtime-database/14-skillhost-host-workload.md) | ⬜ gaps recorded (branch query-grammar found skillhost needs no new query grammar); each gap a candidate iteration | | 15 | [deps: `wo.toml [deps]`](stories/language-runtime-database/15-deps-package-manager.md) | ✅ **landed 2026-08-18** (branch web-framework): [deps] inline tables, git-binary fetch, wo.lock pinning, offline-when-locked, --update-deps, WO-E106/E107; `just deps-accept` 8/0 | -| 16 | [web framework](stories/language-runtime-database/16-web-framework.md) | ✅ **landed 2026-08-19** — writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; h2c parked (§C) behind 8/9f/11. **v1 polish landed 2026-08-20** (branch framework-v1): get/post/put/delete_ helpers, 405+Allow, HEAD, Logging middleware, set_header; `just web-app` 16/0; fixed the interp-borrowed-field emitter crash en route | +| 16 | [web framework](stories/language-runtime-database/16-web-framework.md) | ✅ **landed 2026-08-19** — writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; h2c parked (§C) behind 8/9f/11. **v1 polish landed 2026-08-20** (branch framework-v1): get/post/put/delete_ helpers, 405+Allow, HEAD, Logging middleware, set_header; `just web-app` 16/0; fixed the interp-borrowed-field emitter crash en route. **Auth-in-core landed 2026-08-20**: http/auth.wo (Bearer/Basic, ct_eq, req.principal), web-app dogfoods BearerAuth, gate 17/0 | | 17 | [library projects + `internal/`](stories/language-runtime-database/17-library-projects-internal.md) | ⏸ **PARKED 2026-08-20** (developer directive; framework v1 first) — forks settled, spec + plan approved and ready on branch `library-internal`: kind = "library" key; Go internal/ rule, dep-boundary-only; lib+bin dual; VM/GC untouched by design | --- diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo index 082f950..af4adbf 100644 --- a/docs/examples/web-app/main.wo +++ b/docs/examples/web-app/main.wo @@ -16,16 +16,6 @@ fn view_json(name: Text, price: Int, stock: Int) -> Text { return json.encode(ProductView { name: name, price: price, stock: stock }); } -class Auth { - token: Text - fn before(req: Req) -> ?Resp { - let got = req.headers["authorization"]; - if got == nil { return unauthorized(); } - if got != "Bearer ${self.token}" { return unauthorized(); } - return nil; - } -} - class ListProducts { pad: Int fn handle(req: Req) -> Resp { @@ -107,7 +97,9 @@ fn main(args: multi Text) -> Int { } let app = App { middleware: [], routes: [] }; - app.use_mw(Mw { m: Auth { token: token } }); + -- the framework's Bearer mechanism: constant-time compare, principal + -- attached to req.principal for handlers that want "who is this" + app.use_mw(Mw { m: BearerAuth { token: token, principal: "api" } }); app.get("/products", ListProducts { pad: 0 }); app.get("/products/:name", ShowProduct { pad: 0 }); app.post("/products", CreateProduct { pad: 0 }); diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-framework/README.md index a8f014c..2dbeaaa 100644 --- a/docs/examples/writeonce-framework/README.md +++ b/docs/examples/writeonce-framework/README.md @@ -36,6 +36,14 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", non-conforming handler is a compile error (WO-E205). Middleware is its own interface (`fn before(req: Req) -> ?Resp`; nil = continue, a `Resp` short-circuits). +- **Auth mechanism in core** (`http/auth.wo`): `Authorization` header + parsing (scheme split, case-insensitive), pure-`.wo` base64, a + constant-time comparator (`ct_eq`, no early exit), and the blessed + principal slot — `req.principal` is `""` until an auth middleware + authenticates, then downstream handlers read who it is. `BearerAuth` + and `BasicAuth` (with the `WWW-Authenticate` challenge) ship as + middlewares; POLICY — which routes, which users, where secrets live — + stays in the app, on top of `bearer_token`/`basic_credentials`/`ct_eq`. - **Data layer for free**: handlers use `@table` + the query surface directly — durable, compiler-checked persistence in the same binary. No ORM, no database server. @@ -50,6 +58,22 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", - `Content-Length` bodies only (no chunked encoding), no WebSockets/SSE, JSON-first (no templates). +## The core checklist (what a framework core owes, and where this one is) + +| Core concern | State | +| --- | --- | +| HTTP parsing + connection lifecycle | ✅ `http/parse.wo`, `http/serve.wo` (keep-alive, 400-and-survive, fd-clean, SIGTERM) | +| Routing: path params, method dispatch, precedence | ✅ `:param` captures, first-match-wins, wrong-method = 405 + `Allow` | +| Middleware chain, ordering guarantee | ✅ registration order, `?Resp` short-circuits | +| Request/response types | ✅ `Req`/`Resp` + builders + `set_header` | +| Bearer/Basic auth mechanism + principal | ✅ `http/auth.wo`, `req.principal` | +| Body parsing hooks: JSON | ✅ the language's checked `json.decode` | +| Body parsing hooks: form-encoded, multipart | ⬜ candidate next slices (form first — `parse_query` already decodes the encoding) | +| Error handling → status mapping | 🔶 trap = 500, builders per status; a per-error mapping hook is a candidate slice | +| Body streaming, backpressure | ⏸ needs fibers/shards (iterations 8/11) — whole bodies until then, by design | +| Cancellation propagation | ⏸ process-level only (`env.stopping()`); per-request cancel needs fibers (11) | +| Configuration + graceful shutdown | 🔶 SIGTERM drains and closes clean; config is ctor fields — a config record is a candidate slice | + ## The consuming sample `docs/examples/web-app` — a small storefront importing this framework diff --git a/docs/examples/writeonce-framework/http/auth.wo b/docs/examples/writeonce-framework/http/auth.wo new file mode 100644 index 0000000..1e3944a --- /dev/null +++ b/docs/examples/writeonce-framework/http/auth.wo @@ -0,0 +1,155 @@ +-- http/auth.wo — the auth MECHANISM, framework core: parse the +-- Authorization header, split scheme from credentials, decode Basic's +-- base64, compare secrets in constant time, and attach the authenticated +-- principal to the request (req.principal) so downstream handlers see it. +-- POLICY stays in the app: which routes, which users, where secrets live. + +-- ---- constant-time comparison ------------------------------------------- +-- No early exit on the first differing byte: the accumulator visits every +-- byte, so a wrong secret costs the same time wherever it differs. Unequal +-- lengths answer false up front — length is not the secret. + +pub fn ct_eq(a: Text, b: Text) -> Bool { + if len(a) != len(b) { return false; } + let diff = 0; + let i = 0; + while i < len(a) { + let d = byte_at(a, i) - byte_at(b, i); + diff = diff + d * d; + i = i + 1; + } + return diff == 0; +} + +-- ---- the Authorization header, split ------------------------------------ + +pub typedef AuthHeader = { scheme: Text, credentials: Text } + +-- nil: no Authorization header, or no space between scheme and credentials. +-- The scheme comes back lowercased (schemes are case-insensitive, RFC 9110). +pub fn auth_header(req: Req) -> ?AuthHeader { + let raw = req.headers["authorization"]; + if raw == nil { return nil; } + let sp = index_of(raw, " "); + if sp < 1 { return nil; } + let scheme = to_lower(substr(raw, 0, sp)); + let creds = trim(substr(raw, sp + 1, len(raw) - sp - 1)); + if creds == "" { return nil; } + return AuthHeader { scheme: scheme, credentials: creds }; +} + +-- nil unless the request carries `Authorization: Bearer `. +pub fn bearer_token(req: Req) -> ?Text { + let h = auth_header(req); + if h == nil { return nil; } + if h.scheme != "bearer" { return nil; } + return h.credentials; +} + +-- ---- base64 (RFC 4648, the Basic scheme's encoding) ---------------------- + +fn b64_val(c: Int) -> Int { + if c >= 65 { if c <= 90 { return c - 65; } } -- A-Z -> 0..25 + if c >= 97 { if c <= 122 { return c - 97 + 26; } } -- a-z -> 26..51 + if c >= 48 { if c <= 57 { return c - 48 + 52; } } -- 0-9 -> 52..61 + if c == 43 { return 62; } -- + + if c == 47 { return 63; } -- / + return 0 - 1; -- anything else: bad +} + +-- nil on anything malformed: length not a multiple of 4, a character +-- outside the alphabet, or padding anywhere but the last two positions. +pub fn base64_decode(s: Text) -> ?Text { + let n = len(s); + if n == 0 { return ""; } + if n - (n / 4) * 4 != 0 { return nil; } + let out = ""; + let i = 0; + while i < n { + let c0 = byte_at(s, i); + let c1 = byte_at(s, i + 1); + let c2 = byte_at(s, i + 2); + let c3 = byte_at(s, i + 3); + let last = i + 4 >= n; + -- '=' (61) is legal only as the last one or two characters + if c0 == 61 { return nil; } + if c1 == 61 { return nil; } + if c2 == 61 { if last == false { return nil; } if c3 != 61 { return nil; } } + if c3 == 61 { if last == false { return nil; } } + let v0 = b64_val(c0); + let v1 = b64_val(c1); + if v0 < 0 { return nil; } + if v1 < 0 { return nil; } + out = out .. char_of(v0 * 4 + v1 / 16); + if c2 != 61 { + let v2 = b64_val(c2); + if v2 < 0 { return nil; } + out = out .. char_of((v1 - (v1 / 16) * 16) * 16 + v2 / 4); + if c3 != 61 { + let v3 = b64_val(c3); + if v3 < 0 { return nil; } + out = out .. char_of((v2 - (v2 / 4) * 4) * 64 + v3); + } + } + i = i + 4; + } + return out; +} + +-- ---- Basic credentials --------------------------------------------------- + +pub typedef BasicCreds = { user: Text, pass: Text } + +-- nil unless `Authorization: Basic base64(user:pass)` decodes cleanly. +-- The password may itself contain ':' — the split is on the FIRST colon +-- (RFC 7617: the user-id must not contain one). +pub fn basic_credentials(req: Req) -> ?BasicCreds { + let h = auth_header(req); + if h == nil { return nil; } + if h.scheme != "basic" { return nil; } + let decoded = base64_decode(h.credentials); + if decoded == nil { return nil; } + let colon = index_of(decoded, ":"); + if colon < 0 { return nil; } + return BasicCreds { + user: substr(decoded, 0, colon), + pass: substr(decoded, colon + 1, len(decoded) - colon - 1) + }; +} + +-- ---- the two middlewares ------------------------------------------------- +-- Mechanism only: one shared secret each. An app with a user table writes +-- its own Middleware on top of basic_credentials/bearer_token + ct_eq. + +pub class BearerAuth { + token: Text -- the shared secret + principal: Text -- attached to req.principal on success + fn before(mut req: Req) -> ?Resp { + let got = bearer_token(req); + if got == nil { return unauthorized(); } + if ct_eq(got, self.token) == false { return unauthorized(); } + req.principal = "${self.principal}"; + return nil; + } +} + +pub class BasicAuth { + user: Text + pass: Text + realm: Text -- named in the WWW-Authenticate challenge + fn before(mut req: Req) -> ?Resp { + let c = basic_credentials(req); + if c == nil { return self.challenge(); } + let user_ok = ct_eq(c.user, self.user); + let pass_ok = ct_eq(c.pass, self.pass); -- both always compared + if user_ok == false { return self.challenge(); } + if pass_ok == false { return self.challenge(); } + req.principal = "${c.user}"; + return nil; + } + fn challenge() -> Resp { + let r = unauthorized(); + set_header(r, "www-authenticate", "Basic realm=\"${self.realm}\""); + return r; + } +} diff --git a/docs/examples/writeonce-framework/http/parse.wo b/docs/examples/writeonce-framework/http/parse.wo index 1ddea93..bc5bdba 100644 --- a/docs/examples/writeonce-framework/http/parse.wo +++ b/docs/examples/writeonce-framework/http/parse.wo @@ -143,6 +143,6 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed { } let req = Req { method: method, path: path, params: {}, query: query, - headers: headers, body: body }; + headers: headers, body: body, principal: "" }; return Parsed { closed: false, ok: true, req: req, rest: rest }; } diff --git a/docs/examples/writeonce-framework/http/types.wo b/docs/examples/writeonce-framework/http/types.wo index cb8afe0..21f79c8 100644 --- a/docs/examples/writeonce-framework/http/types.wo +++ b/docs/examples/writeonce-framework/http/types.wo @@ -3,12 +3,14 @@ -- serve loop in http/serve.wo, dispatch in router/ and app.wo. pub typedef Req = { - method: Text, -- uppercased: GET, POST, ... - path: Text, -- decoded path, query stripped - params: map, -- :param captures, filled by the router - query: map, -- decoded query-string pairs - headers: map, -- names lowercased on read - body: Text -- exactly Content-Length bytes ("" if none) + method: Text, -- uppercased: GET, POST, ... + path: Text, -- decoded path, query stripped + params: map, -- :param captures, filled by the router + query: map, -- decoded query-string pairs + headers: map, -- names lowercased on read + body: Text, -- exactly Content-Length bytes ("" if none) + principal: Text -- who this is: "" until an auth middleware + -- (http/auth.wo) authenticates the request } pub typedef Resp = { diff --git a/docs/examples/writeonce-framework/router/router.wo b/docs/examples/writeonce-framework/router/router.wo index 3f6be22..d1c4c6e 100644 --- a/docs/examples/writeonce-framework/router/router.wo +++ b/docs/examples/writeonce-framework/router/router.wo @@ -11,8 +11,10 @@ pub interface Handler { fn handle(req: Req) -> Resp } +-- `mut req`: middleware may WRITE the request — auth attaches the +-- authenticated principal (req.principal) for downstream handlers. pub interface Middleware { - fn before(req: Req) -> ?Resp + fn before(mut req: Req) -> ?Resp } -- One route: method + pattern + the handler value. Built with a ctor @@ -35,7 +37,7 @@ pub class Mw { -- convention (every stateless handler carries one Int field). pub class Logging { pad: Int - fn before(req: Req) -> ?Resp { + fn before(mut req: Req) -> ?Resp { print_err("${req.method} ${req.path}"); return nil; } diff --git a/docs/stories/language-runtime-database/16-web-framework.md b/docs/stories/language-runtime-database/16-web-framework.md index 2c41ecc..6953333 100644 --- a/docs/stories/language-runtime-database/16-web-framework.md +++ b/docs/stories/language-runtime-database/16-web-framework.md @@ -24,6 +24,19 @@ > `let`/assignment boundaries uncopied — `copy_place_text` now sees through > `Interp` (`run/interp-borrowed-field`). > +> **Auth-in-core LANDED 2026-08-20** (same branch): `http/auth.wo` — the +> MECHANISM per the core doctrine: `Authorization` parsing, pure-`.wo` +> base64 (RFC 4648), constant-time `ct_eq`, `req.principal` as the blessed +> "who is this" slot (Middleware.before now takes `mut req` to write it), +> `BearerAuth` + `BasicAuth` (WWW-Authenticate challenge) middlewares. +> Policy stays app-side. Probe matrix 26/26 incl. RFC vectors, ASan-clean; +> web-app dogfoods `BearerAuth` (its hand-rolled Auth deleted); +> `just web-app` 17/0. The framework README now carries the core CHECKLIST: +> what is ✅ (parsing, routing, middleware, types, auth, JSON), what is a +> candidate slice (form/multipart, error-mapping hook, config record), and +> what parks behind 8/11 by design (streaming, backpressure, per-request +> cancellation). +> > The framework is written IN writeonce and imported > like any dependency (iteration 15 is the prerequisite). TLS terminates at a > reverse proxy — browsers get TLS+ALPN+h2 from nginx/caddy while the diff --git a/scripts/web-app-accept.sh b/scripts/web-app-accept.sh index 6fc1221..8b6945d 100755 --- a/scripts/web-app-accept.sh +++ b/scripts/web-app-accept.sh @@ -86,6 +86,23 @@ expect() { # name got want-status [want-body-substring] # ---- 2..10 the storefront matrix ---- expect "401 without the token" "$(hit GET /products '' no)" 401 + +# wrong bearer token: the framework's constant-time compare denies (401) +wt="$(timeout 5 python3 - "$PORT" <<'PYEOF' +import socket, sys +port = int(sys.argv[1]) +s = socket.create_connection(("127.0.0.1", port), timeout=3) +s.sendall(b"GET /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer wr0ng!\r\nconnection: close\r\ncontent-length: 0\r\n\r\n") +d = b"" +while True: + got = s.recv(2000) + if not got: break + d += got +print(d.decode().splitlines()[0].split(" ")[1]) +PYEOF +)" +[[ "$wt" == "401" ]] && ok "401 on a wrong bearer token (ct_eq)" \ + || bad "wrong-token" "got $wt" expect "empty list" "$(hit GET /products)" 200 "[]" expect "create product (201)" "$(hit POST /products '{"name":"mug","price":900,"stock":5}')" 201 '"name":"mug"' expect "duplicate name is 409 (@unique)" "$(hit POST /products '{"name":"mug","price":1,"stock":1}')" 409