Skip to content

Commit 6ee8a9f

Browse files
committed
feat(moonapi): add OAuth2 password-bearer JWT security, form/file extractors, and response_model.
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
1 parent 56a9ccf commit 6ee8a9f

16 files changed

Lines changed: 1896 additions & 5 deletions

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,17 @@ let docs_page = @moonapi.swagger_ui() // a Swagger UI page
4646
- **Multi-version OpenAPI**`App::openapi` / `openapi_json` emit **Swagger 2.0, OpenAPI 3.0.3, and OpenAPI 3.1.0** from the same routes and descriptors (3.x `requestBody` + `components/schemas`; 2.0 body-parameter + `definitions`), because a good FastAPI is not pinned to one spec version.
4747
- **Typed body extractors**`Context::body[T]` deserialises the JSON body into a `derive(FromJson)` struct; `Context::body_validated[T]` first checks it against the endpoint descriptor and returns either the built value or a FastAPI-shaped `422` error list — schema emission, validation, and deserialisation all off one descriptor.
4848
- **Dependency injection** — a `Container` (provider registry + `dependency_overrides`) with request-scoped, one-shot resolution (per-request caching) and `yield`-style teardown run LIFO around the handler — the explicit MoonBit equivalent of FastAPI's `Depends`.
49+
- **OAuth2 + JWT security** — a `/token` password-grant endpoint issues an HS256 JWT (`create_access_token`), and an `OAuth2PasswordBearer` reads the `Authorization: Bearer` header, verifies the token, and enforces scopes: `401` on a missing or invalid/expired token, `403` when a valid token lacks a required scope. The SHA-256 / HMAC-SHA256 pair is self-built (`crypto.mbt`), checked against the NIST and RFC 4231 vectors; the `alg: "none"` downgrade is refused and signatures compare in constant time.
50+
- **Form & file extractors**`Context::form` parses both an `application/x-www-form-urlencoded` body (percent- and `+`-decoded) and a `multipart/form-data` body, splitting the boundary stream into `FormField`s and byte-exact `UploadFile`s (filename + content-type + raw bytes). `Context::oauth2_password_form` reads the OAuth2 password form off it.
51+
- **response_model**`filter_response` / `json_model` validate a handler's return value against a declared `Schema` and project it down to exactly the model's fields, so a route can hold a richer object internally than it exposes (an id, a password hash) and still emit only what it promised.
4952
- **Swagger UI**`swagger_ui()` returns a ready-to-serve documentation page.
5053
- **Responses**`text` and `json` helpers over `moonasgi.Response`.
5154

5255
Verified across all backends (`wasm`, `wasm-gc`, `js`, `native`) in CI, 0 warnings under `--deny-warn`.
5356

5457
## Roadmap (transliterating FastAPI)
5558

56-
The descriptor tree (`Endpoint` / `Param` / `Schema`) is in place — walked once for full OpenAPI 3.1 body schemas and validation — and now the typed `derive(FromJson)` body extractors (`Context::body` / `body_validated`) and the dependency-injection container (provider registry + request-scoped resolution + `yield` teardown + `dependency_overrides`) sit on top of it. Next: the full extractor set (`Header` / `Cookie` / `Form` / `File` with a self-built multipart parser); security (OAuth2 password + scopes, JWT); response-model filtering, exception handlers, CORS / GZip middleware, background tasks, streaming / SSE, WS routes, and codegen'd request schemas via `moonctl`.
59+
The descriptor tree (`Endpoint` / `Param` / `Schema`) is in place — walked once for full OpenAPI 3.1 body schemas and validation — with the typed `derive(FromJson)` body extractors (`Context::body` / `body_validated`) and the dependency-injection container on top. This release adds the security and form layers: OAuth2 password-bearer with self-built HS256 JWT and scopes, the `Form` / `File` extractors over a self-built urlencoded + multipart parser, and `response_model` filtering. Next: RS256 / ES256 signing; exception handlers; CORS / GZip middleware; background tasks; streaming / SSE; WS routes; sub-app mounting; and codegen'd request schemas via `moonctl`. Security scheme objects in the emitted OpenAPI document are still to come.
5760

5861
## License
5962

crypto.mbt

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// The HS256 signing primitives behind OAuth2 / JWT. MoonBit's core ships no
2+
// crypto, and moonapi can't reach for moonzero's copy (moonzero depends on
3+
// moonapi, so the import would cycle), so the SHA-256 / HMAC pair lives here.
4+
// FastAPI leans on python-jose / passlib for the same job.
5+
6+
///|
7+
/// The 64 SHA-256 round constants (FIPS 180-4 §4.2.2): the first 32 bits of the
8+
/// fractional parts of the cube roots of the first 64 primes.
9+
let sha256_k : Array[UInt] = [
10+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
11+
0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
12+
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
13+
0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
14+
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
15+
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
16+
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
17+
0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
18+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
19+
0xc67178f2,
20+
]
21+
22+
///|
23+
/// A 32-bit right-rotation, the diffusion operator SHA-256 is built from.
24+
fn rotr32(x : UInt, n : Int) -> UInt {
25+
(x >> n) | (x << (32 - n))
26+
}
27+
28+
///|
29+
/// SHA-256 (FIPS 180-4): hash an arbitrary byte string to a 32-byte digest. The
30+
/// full message schedule and 64-round compression over 512-bit blocks with the
31+
/// standard length-padding, checked against the NIST vectors for `""` and
32+
/// `"abc"`. The building block for `hmac_sha256`, and through it JWT HS256.
33+
pub fn sha256(msg : Bytes) -> Bytes {
34+
let mut h0 : UInt = 0x6a09e667
35+
let mut h1 : UInt = 0xbb67ae85
36+
let mut h2 : UInt = 0x3c6ef372
37+
let mut h3 : UInt = 0xa54ff53a
38+
let mut h4 : UInt = 0x510e527f
39+
let mut h5 : UInt = 0x9b05688c
40+
let mut h6 : UInt = 0x1f83d9ab
41+
let mut h7 : UInt = 0x5be0cd19
42+
let bitlen = (msg.length() * 8).to_uint64()
43+
let buf = Buffer()
44+
buf.write_bytes(msg[:])
45+
buf.write_byte(b'\x80')
46+
while buf.length() % 64 != 56 {
47+
buf.write_byte(b'\x00')
48+
}
49+
for i = 7; i >= 0; i = i - 1 {
50+
buf.write_byte(((bitlen >> (i * 8)) & 0xFF).to_byte())
51+
}
52+
let data = buf.to_bytes()
53+
let w : Array[UInt] = Array::make(64, 0U)
54+
let nblocks = data.length() / 64
55+
for b = 0; b < nblocks; b = b + 1 {
56+
let off = b * 64
57+
for i = 0; i < 16; i = i + 1 {
58+
let j = off + i * 4
59+
w[i] = (data[j].to_int().reinterpret_as_uint() << 24) |
60+
(data[j + 1].to_int().reinterpret_as_uint() << 16) |
61+
(data[j + 2].to_int().reinterpret_as_uint() << 8) |
62+
data[j + 3].to_int().reinterpret_as_uint()
63+
}
64+
for i = 16; i < 64; i = i + 1 {
65+
let s0 = rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ (w[i - 15] >> 3)
66+
let s1 = rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ (w[i - 2] >> 10)
67+
w[i] = w[i - 16] + s0 + w[i - 7] + s1
68+
}
69+
let mut a = h0
70+
let mut bb = h1
71+
let mut c = h2
72+
let mut d = h3
73+
let mut e = h4
74+
let mut f = h5
75+
let mut g = h6
76+
let mut hh = h7
77+
for i = 0; i < 64; i = i + 1 {
78+
let s1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25)
79+
let ch = (e & f) ^ (e.lnot() & g)
80+
let t1 = hh + s1 + ch + sha256_k[i] + w[i]
81+
let s0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22)
82+
let maj = (a & bb) ^ (a & c) ^ (bb & c)
83+
let t2 = s0 + maj
84+
hh = g
85+
g = f
86+
f = e
87+
e = d + t1
88+
d = c
89+
c = bb
90+
bb = a
91+
a = t1 + t2
92+
}
93+
h0 = h0 + a
94+
h1 = h1 + bb
95+
h2 = h2 + c
96+
h3 = h3 + d
97+
h4 = h4 + e
98+
h5 = h5 + f
99+
h6 = h6 + g
100+
h7 = h7 + hh
101+
}
102+
let out = Buffer()
103+
for hv in [h0, h1, h2, h3, h4, h5, h6, h7] {
104+
out.write_byte((hv >> 24).to_byte())
105+
out.write_byte((hv >> 16).to_byte())
106+
out.write_byte((hv >> 8).to_byte())
107+
out.write_byte(hv.to_byte())
108+
}
109+
out.to_bytes()
110+
}
111+
112+
///|
113+
/// HMAC-SHA256 (RFC 2104): a keyed MAC over `sha256`. A key longer than the
114+
/// 64-byte block is hashed first; a shorter key is zero-padded. The message is
115+
/// authenticated as `H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ msg))`. Checked against RFC
116+
/// 4231 test case 2. This is the signature function behind JWT HS256.
117+
pub fn hmac_sha256(key : Bytes, msg : Bytes) -> Bytes {
118+
let block = 64
119+
let k0 = Buffer()
120+
if key.length() > block {
121+
k0.write_bytes(sha256(key)[:])
122+
} else {
123+
k0.write_bytes(key[:])
124+
}
125+
while k0.length() < block {
126+
k0.write_byte(b'\x00')
127+
}
128+
let kb = k0.to_bytes()
129+
let ipad = Buffer()
130+
let opad = Buffer()
131+
for i = 0; i < block; i = i + 1 {
132+
ipad.write_byte((kb[i].to_int() ^ 0x36).to_byte())
133+
opad.write_byte((kb[i].to_int() ^ 0x5c).to_byte())
134+
}
135+
ipad.write_bytes(msg[:])
136+
let inner = sha256(ipad.to_bytes())
137+
opad.write_bytes(inner[:])
138+
sha256(opad.to_bytes())
139+
}
140+
141+
///|
142+
/// A constant-time byte-string equality: it inspects every byte of both inputs
143+
/// regardless of where they first differ, so an attacker can't recover a valid
144+
/// signature byte-by-byte from response timing. Unequal lengths return `false`
145+
/// at once (length isn't secret). Used to compare JWT signatures.
146+
pub fn constant_time_eq(a : Bytes, b : Bytes) -> Bool {
147+
if a.length() != b.length() {
148+
return false
149+
}
150+
let mut diff = 0
151+
for i = 0; i < a.length(); i = i + 1 {
152+
diff = diff | (a[i].to_int() ^ b[i].to_int())
153+
}
154+
diff == 0
155+
}

crypto_wbtest.mbt

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
///|
2+
/// Lowercase hex of a byte string — test helper for comparing digests to the
3+
/// published NIST / RFC vectors.
4+
fn to_hex(b : Bytes) -> String {
5+
let digits = "0123456789abcdef"
6+
let sb = StringBuilder::new()
7+
for i = 0; i < b.length(); i = i + 1 {
8+
let v = b[i].to_int()
9+
sb.write_char(digits[v / 16].unsafe_to_char())
10+
sb.write_char(digits[v % 16].unsafe_to_char())
11+
}
12+
sb.to_string()
13+
}
14+
15+
///|
16+
test "sha256 matches the NIST vectors" {
17+
assert_eq(
18+
to_hex(sha256(@utf8.encode(""))),
19+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
20+
)
21+
assert_eq(
22+
to_hex(sha256(@utf8.encode("abc"))),
23+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
24+
)
25+
// A message spanning two 512-bit blocks (56 bytes forces a second block).
26+
assert_eq(
27+
to_hex(
28+
sha256(
29+
@utf8.encode("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
30+
),
31+
),
32+
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1",
33+
)
34+
}
35+
36+
///|
37+
test "hmac_sha256 matches RFC 4231 test case 2" {
38+
// key = "Jefe", data = "what do ya want for nothing?"
39+
let mac = hmac_sha256(
40+
@utf8.encode("Jefe"),
41+
@utf8.encode("what do ya want for nothing?"),
42+
)
43+
assert_eq(
44+
to_hex(mac),
45+
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
46+
)
47+
}
48+
49+
///|
50+
test "hmac_sha256 with a key longer than the block is hashed first" {
51+
// RFC 4231 test case 3 uses a 131-byte key; here just assert stability +
52+
// that the long-key branch produces a 32-byte digest distinct from a short one.
53+
let long_key = Bytes::make(131, b'\xaa')
54+
let mac = hmac_sha256(
55+
long_key,
56+
@utf8.encode("Test Using Larger Than Block-Size Key - Hash Key First"),
57+
)
58+
assert_eq(mac.length(), 32)
59+
assert_eq(
60+
to_hex(mac),
61+
"60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
62+
)
63+
}
64+
65+
///|
66+
test "constant_time_eq: equal, differing, and length-mismatched" {
67+
assert_eq(constant_time_eq(b"secret", b"secret"), true)
68+
assert_eq(constant_time_eq(b"secret", b"secreu"), false)
69+
assert_eq(constant_time_eq(b"secret", b"secre"), false)
70+
assert_eq(constant_time_eq(b"", b""), true)
71+
}

0 commit comments

Comments
 (0)