writeonce/docs/examples/writeonce-serve/http/auth.wo
shoney.arickathil ef37b8ffa2 feat(serve+view): file serving, downloads, supported systems; rename
- 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>
2026-08-25 04:41:00 +02:00

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