Skip to content

Commit 7aba689

Browse files
committed
feat(moonapi): HTTP Basic and API-key security extractors (FastAPI HTTPBasic / APIKey*).
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
1 parent 1f800b2 commit 7aba689

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

security_extractors.mbt

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// The non-OAuth2 security schemes' runtime extractors (← FastAPI's `HTTPBasic` and
2+
// `APIKeyHeader` / `APIKeyQuery` / `APIKeyCookie`). `security_scheme.mbt` already describes these to
3+
// clients under `securitySchemes`; this is the enforcement side — pulling the credential off the
4+
// request so a route can authenticate against it, the same way `Context::bearer_token` does for
5+
// OAuth2.
6+
7+
///|
8+
/// The credentials carried in an HTTP Basic `Authorization` header (← FastAPI's
9+
/// `HTTPBasicCredentials`): the username and password from `base64(username:password)`.
10+
pub(all) struct HttpBasicCredentials {
11+
username : String
12+
password : String
13+
}
14+
15+
///|
16+
/// The index of the first `:` in `s`, or `-1`.
17+
fn first_colon(s : String) -> Int {
18+
for i = 0; i < s.length(); i = i + 1 {
19+
if s[i] == ':' {
20+
return i
21+
}
22+
}
23+
-1
24+
}
25+
26+
///|
27+
/// Parse an HTTP Basic `Authorization` header value into credentials (← FastAPI's `HTTPBasic`),
28+
/// or `None`. Matches the `Basic` scheme case-insensitively (RFC 7617), base64-decodes the rest, and
29+
/// splits on the first colon so a password may itself contain colons.
30+
pub fn parse_basic_auth(header : String) -> HttpBasicCredentials? {
31+
let trimmed = trim_spaces(header)
32+
let prefix = "basic "
33+
guard trimmed.length() >= prefix.length() else { return None }
34+
guard trimmed[0:prefix.length()].to_owned().to_lower() == prefix else {
35+
return None
36+
}
37+
let encoded = trim_spaces(trimmed[prefix.length():].to_owned())
38+
let decoded = @utf8.decode_lossy(@base64.decode_lossy(encoded)[:])
39+
let colon = first_colon(decoded)
40+
guard colon >= 0 else { return None }
41+
Some({
42+
username: decoded[0:colon].to_owned(),
43+
password: decoded[colon + 1:].to_owned(),
44+
})
45+
}
46+
47+
///|
48+
/// The HTTP Basic credentials on this request (← FastAPI's `HTTPBasic` dependency), or `None`.
49+
pub fn Context::http_basic(self : Context) -> HttpBasicCredentials? {
50+
match self.request.header("authorization") {
51+
Some(h) => parse_basic_auth(h)
52+
None => None
53+
}
54+
}
55+
56+
///|
57+
/// The API key carried in the request header `name` (← FastAPI's `APIKeyHeader`), or `None`. Header
58+
/// names are matched against the request's lower-cased headers.
59+
pub fn Context::api_key_header(self : Context, name : String) -> String? {
60+
self.request.header(name.to_lower())
61+
}
62+
63+
///|
64+
/// The API key carried in the query parameter `name` (← FastAPI's `APIKeyQuery`), or `None`.
65+
pub fn Context::api_key_query(self : Context, name : String) -> String? {
66+
self.query(name)
67+
}
68+
69+
///|
70+
/// The API key carried in the cookie `name` (← FastAPI's `APIKeyCookie`), or `None`.
71+
pub fn Context::api_key_cookie(self : Context, name : String) -> String? {
72+
self.cookie(name)
73+
}

security_extractors_wbtest.mbt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// HTTP Basic / API-key runtime extractors (← FastAPI's `HTTPBasic` / `APIKey*`).
2+
3+
///|
4+
test "security: HTTP Basic auth parses username and password from the Authorization header" {
5+
// RFC 7617 §2 example: "Aladdin:open sesame".
6+
guard parse_basic_auth("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==") is Some(creds) else {
7+
fail("expected credentials")
8+
}
9+
assert_eq(creds.username, "Aladdin")
10+
assert_eq(creds.password, "open sesame")
11+
// The scheme name is case-insensitive.
12+
guard parse_basic_auth("basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==") is Some(lower) else {
13+
fail("expected credentials for a lowercase scheme")
14+
}
15+
assert_eq(lower.username, "Aladdin")
16+
// A password containing a colon splits only on the first one.
17+
guard parse_basic_auth("Basic dTpwOnE=") is Some(colonpw) else {
18+
fail("expected credentials")
19+
}
20+
// "u:p:q" → username "u", password "p:q".
21+
assert_eq(colonpw.username, "u")
22+
assert_eq(colonpw.password, "p:q")
23+
// A non-Basic scheme, or a value with no colon, yields None.
24+
assert_eq(parse_basic_auth("Bearer abc") is None, true)
25+
assert_eq(parse_basic_auth("Basic bm9jb2xvbg==") is None, true)
26+
}

0 commit comments

Comments
 (0)