// OAuth2 password bearer flow — FastAPI's `OAuth2PasswordBearer` + `Security`.
// A token endpoint reads an `OAuth2PasswordRequestForm` off the urlencoded body
// and issues an HS256 JWT; a protected route hands its request to a `Security`
// value, which pulls the `Authorization: Bearer ` header, verifies the
// JWT, checks the required scopes, and either yields the authenticated user or
// the 401/403 response to return. With no reflection there's no auto-injection,
// so the handler asks the `Security` explicitly and matches the `Result` — the
// same shape as axum extractors or Go middleware that stashes the claims.
///|
/// The parsed OAuth2 password-grant form (← FastAPI's `OAuth2PasswordRequestForm`).
/// The token endpoint reads `username` / `password` to authenticate and `scopes`
/// (the space-delimited `scope` field, split into a list) to stamp into the
/// issued token. `grant_type` is `"password"` for this flow; `client_id` /
/// `client_secret` are optional confidential-client credentials.
pub(all) struct OAuth2PasswordRequestForm {
grant_type : String
username : String
password : String
scopes : Array[String]
client_id : String?
client_secret : String?
} derive(Eq)
///|
/// Split a space-delimited scope string into its scopes, dropping empty runs —
/// the OAuth2 `scope` form field is one string like `"read write"`.
fn split_scopes(s : String) -> Array[String] {
let out : Array[String] = []
let sb = StringBuilder()
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() == 0x20 {
if sb.to_string() != "" {
out.push(sb.to_string())
}
sb.reset()
} else {
sb.write_char(s[i].unsafe_to_char())
}
}
if sb.to_string() != "" {
out.push(sb.to_string())
}
out
}
///|
/// Read an `OAuth2PasswordRequestForm` off the request's urlencoded (or
/// multipart) body. `None` when neither `username` nor `password` is present —
/// the body isn't a password-grant form at all.
pub fn Context::oauth2_password_form(
self : Context,
) -> OAuth2PasswordRequestForm? {
let form = self.form()
let username = form.field("username")
let password = form.field("password")
match (username, password) {
(None, None) => None
_ => {
let scope = form.field("scope").unwrap_or("")
Some({
grant_type: form.field("grant_type").unwrap_or(""),
username: username.unwrap_or(""),
password: password.unwrap_or(""),
scopes: split_scopes(scope),
client_id: form.field("client_id"),
client_secret: form.field("client_secret"),
})
}
}
}
///|
/// The identity a verified token carries, injected into a protected handler
/// (← the object FastAPI's `get_current_user` returns). `subject` is the `sub`
/// claim, `scopes` the granted scopes, and `claims` the whole verified payload
/// for anything else the handler needs (`exp`, custom claims).
pub(all) struct AuthenticatedUser {
subject : String
scopes : Array[String]
claims : Map[String, Json]
}
///|
/// Whether this user was granted `scope`.
pub fn AuthenticatedUser::has_scope(
self : AuthenticatedUser,
scope : String,
) -> Bool {
self.scopes.contains(scope)
}
///|
/// Read the `scopes` claim, accepting either a JSON array of strings or a single
/// space-delimited string (both are seen in the wild). Absent -> no scopes.
fn scopes_from_claims(claims : Map[String, Json]) -> Array[String] {
match claims.get("scopes") {
Some(Array(a)) => {
let out : Array[String] = []
for v in a {
match v {
String(s) => out.push(s)
_ => ()
}
}
out
}
Some(String(s)) => split_scopes(s)
_ => []
}
}
///|
/// The `sub` claim as a string, `""` if absent or non-string.
fn subject_from_claims(claims : Map[String, Json]) -> String {
match claims.get("sub") {
Some(String(s)) => s
_ => ""
}
}
///|
/// An OAuth2 password-bearer security scheme (← FastAPI's `OAuth2PasswordBearer`).
/// Holds the `token_url` (the endpoint that issues tokens, surfaced to API docs)
/// and the shared HS256 `secret` used to verify presented tokens.
pub(all) struct OAuth2PasswordBearer {
token_url : String
secret : String
}
///|
/// Build a password-bearer scheme pointing at the token endpoint at `token_url`,
/// verifying tokens with `secret`.
pub fn OAuth2PasswordBearer::new(
token_url : String,
secret : String,
) -> OAuth2PasswordBearer {
{ token_url, secret, }
}
///|
/// Pull the bearer token out of the `Authorization: Bearer ` header,
/// `None` if the header is absent or isn't a bearer credential. The scheme name
/// is matched case-insensitively, as RFC 6750 requires.
pub fn Context::bearer_token(self : Context) -> String? {
match self.request.header("authorization") {
None => None
Some(h) => {
let trimmed = trim_spaces(h)
let prefix = "bearer "
if trimmed.length() < prefix.length() {
return None
}
let scheme = trimmed[0:prefix.length()].to_owned().to_lower()
if scheme != prefix {
return None
}
Some(trim_spaces(trimmed[prefix.length():].to_owned()))
}
}
}
///|
/// A `401 Unauthorized` with a `WWW-Authenticate: Bearer` challenge and a
/// `{"detail": ...}` body — FastAPI's response for a missing or invalid token.
fn unauthorized(detail : String) -> @moonasgi.Response {
let body : Map[String, Json] = Map([("detail", detail.to_json())])
@moonasgi.Response::new(
401,
[("content-type", "application/json"), ("www-authenticate", "Bearer")],
@utf8.encode(body.to_json().stringify()),
)
}
///|
/// A `403 Forbidden` with a `{"detail": ...}` body — returned when the token is
/// valid but lacks a required scope. The token authenticated the caller, so this
/// is authorization, not authentication: 403, not 401.
fn forbidden(detail : String) -> @moonasgi.Response {
let body : Map[String, Json] = Map([("detail", detail.to_json())])
@moonasgi.Response::new(
403,
[("content-type", "application/json")],
@utf8.encode(body.to_json().stringify()),
)
}
///|
/// Authenticate a request against this scheme and enforce `scopes`
/// (← FastAPI's `Security(get_current_user, scopes=[...])`). On success returns
/// the `AuthenticatedUser`; otherwise the response to return:
///
/// - no bearer token -> `401` `{"detail":"Not authenticated"}`
/// - malformed / bad-signature / expired / not-yet-valid token -> `401`
/// `{"detail":"Could not validate credentials"}`
/// - valid token missing a required scope -> `403` `{"detail":"Not enough permissions"}`
///
/// `now_secs` is the verification time (Unix seconds), passed in so the check
/// stays pure and testable on every backend.
pub fn OAuth2PasswordBearer::authenticate(
self : OAuth2PasswordBearer,
ctx : Context,
now_secs : Int64,
scopes? : Array[String] = [],
) -> Result[AuthenticatedUser, @moonasgi.Response] {
let token = match ctx.bearer_token() {
None => return Err(unauthorized("Not authenticated"))
Some(t) => t
}
let claims = jwt_verify(token, self.secret, now_secs) catch {
_ => return Err(unauthorized("Could not validate credentials"))
}
let user : AuthenticatedUser = {
subject: subject_from_claims(claims),
scopes: scopes_from_claims(claims),
claims,
}
for required in scopes {
if !user.has_scope(required) {
return Err(forbidden("Not enough permissions"))
}
}
Ok(user)
}
// -- token issuance -----------------------------------------------------------
///|
/// Mint an HS256 access token for `subject` (← FastAPI's `create_access_token`).
/// Stamps `sub`, `iat` (= `now_secs`), `exp` (= `now_secs + expires_in_secs`),
/// and, when non-empty, `scopes`; `extra` merges in any further claims. Times are
/// Unix seconds. `secret` is the shared HS256 key.
pub fn create_access_token(
subject : String,
secret : String,
now_secs : Int64,
scopes? : Array[String] = [],
expires_in_secs? : Int64 = 3600,
extra? : Map[String, Json] = Map([]),
) -> String {
let claims : Map[String, Json] = Map([])
for k, v in extra {
claims[k] = v
}
// `iat` / `exp` are RFC 7519 NumericDates — JSON numbers, so an off-the-shelf
// verifier reads them (`Int64::to_json` would emit a string).
claims["sub"] = subject.to_json()
claims["iat"] = now_secs.to_double().to_json()
claims["exp"] = (now_secs + expires_in_secs).to_double().to_json()
if scopes.length() > 0 {
let arr : Array[Json] = []
for s in scopes {
arr.push(s.to_json())
}
claims["scopes"] = arr.to_json()
}
jwt_sign(claims, secret)
}
///|
/// The `200` token response body `{"access_token": ..., "token_type": "bearer"}`
/// — the OAuth2 password-grant reply FastAPI's token endpoint returns.
pub fn token_response(access_token : String) -> @moonasgi.Response {
let body : Map[String, Json] = Map([
("access_token", access_token.to_json()),
("token_type", "bearer".to_json()),
])
json(200, body.to_json())
}