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:
parent
3d33b7bf58
commit
c17a8a2558
9 changed files with 234 additions and 21 deletions
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
155
docs/examples/writeonce-framework/http/auth.wo
Normal file
155
docs/examples/writeonce-framework/http/auth.wo
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue