- rename the two libraries: writeonce-framework -> writeonce-serve
(`use serve`), wo-html -> writeonce-view (`use view`). Names say the
ROLE now; every sample, script, gate and live doc follows
- stories/specs/plans keep the old names: they are dated records, and
both library READMEs carry a "renamed 2026-08-25" note
- serve/http/files.wo: StaticFiles { dir, max_bytes } — traversal
refused not normalised, extension content types, attachment
disposition for archives. Lifted out of the shop, which had said in
a comment that it belonged in the framework
- shop drops its private copy and mounts the framework's
- site: /dl/*path over $WO_DIST (default ./dist), 16 MiB ceiling
- /install gains supported systems — Linux x86-64, glibc >= 2.38,
not musl — read off `file` and the binaries' GLIBC_ symbol
versions, not off a wish list; plus GitHub release as primary,
/dl as mirror, and the sha256 verify step
- site-accept: 17 -> 21 checks (supported systems, gzip download with
a binary-safe probe, checksum, /dl traversal 404)
Verified on 192.168.0.165: the real 960,820-byte tarball downloads
as application/gzip and its sha256 matches the published digest.
Gates: oop-accept MET, site 21/0, web-app 46/0, fibers 10/0,
db-actor 8/0; shop rebuilt and its /assets served by the framework.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
5.4 KiB
Text
155 lines
5.4 KiB
Text
-- 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;
|
|
}
|
|
}
|