feat(framework): auth in core — Bearer/Basic mechanism + principal slot

- http/auth.wo: auth_header (scheme split, case-insensitive, RFC 9110),
  bearer_token, basic_credentials (first-colon split, RFC 7617),
  pure-.wo base64_decode (RFC 4648, strict padding), ct_eq constant-time
  compare (no early exit, both Basic fields always compared)
- req.principal: the blessed "who is this" slot, "" until authenticated;
  Middleware.before now takes mut req so auth can write it
- BearerAuth { token, principal } and BasicAuth { user, pass, realm }
  middlewares; BasicAuth answers the WWW-Authenticate challenge; policy
  (routes/users/secrets) stays app-side on the exposed fns
- web-app dogfoods BearerAuth; its hand-rolled Auth class deleted
- probe matrix 26/26 (RFC 4648 vectors, rfc7617 pair, pass-with-colon,
  bad padding/chars/length, deny paths, challenge header) release+ASan
- gate grows 16 -> 17: wrong bearer token answers 401 over the wire
- README: auth bullet + the core CHECKLIST (done / candidate / parked
  behind 8-11 by design); story 16 + board record the landing
- all gates green: web-app 17/0, oop-e2e 89/0, deps-accept 8/0,
  log-watcher 7/0, employee 8/0, woc-test green

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-20 03:15:16 +02:00
parent d84b72f0b4
commit fb86156d04
9 changed files with 234 additions and 21 deletions

View file

@ -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 |
---

View file

@ -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 });

View file

@ -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

View file

@ -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 <token>`.
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;
}
}

View file

@ -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 };
}

View file

@ -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<Text, Text>, -- :param captures, filled by the router
query: map<Text, Text>, -- decoded query-string pairs
headers: map<Text, Text>, -- 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<Text, Text>, -- :param captures, filled by the router
query: map<Text, Text>, -- decoded query-string pairs
headers: map<Text, Text>, -- 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 = {

View file

@ -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;
}

View file

@ -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

View file

@ -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