// The enforcement half of the security schemes. `security_scheme.mbt` describes
// a scheme to clients; this is what stands in front of a route that names it —
// FastAPI's `Security(APIKeyHeader(...))`, `Security(HTTPBasic())` and friends,
// which are a declaration and a guard at once. Every guard here answers the same
// question the OAuth2 one does: given a request, the scopes the route asked for
// and the verification time, is this caller admitted, and if not, with what.
//
// `auto_error` is FastAPI's, and means the same: with it off, a missing or bad
// credential does not refuse the request — the guard yields an anonymous caller
// and the route decides for itself, reading the credential through the
// `Context` extractors.
///|
/// The scopes a route required at the point its security guard runs (← FastAPI's
/// `SecurityScopes`). A guard reads them to check what the caller was granted
/// and to build the `WWW-Authenticate` challenge a `401` owes the client.
pub struct SecurityScopes {
scopes : Array[String]
}
///|
/// The scopes required at one call site; empty means authentication only.
pub fn SecurityScopes::new(scopes? : Array[String] = []) -> SecurityScopes {
{ scopes, }
}
///|
/// The required scopes, in declaration order.
pub fn SecurityScopes::list(self : SecurityScopes) -> Array[String] {
self.scopes
}
///|
/// The scopes as OAuth2's single space-delimited string (← `SecurityScopes.scope_str`).
pub fn SecurityScopes::scope_str(self : SecurityScopes) -> String {
self.scopes.join(" ")
}
///|
/// The `WWW-Authenticate` value a bearer challenge carries: `Bearer` on its own,
/// or `Bearer scope="a b"` when the route requires scopes, which is how RFC 6750
/// §3 tells a client what it was missing.
pub fn SecurityScopes::challenge(self : SecurityScopes) -> String {
if self.scopes.is_empty() {
"Bearer"
} else {
"Bearer scope=\"" + self.scope_str() + "\""
}
}
///|
/// The caller an `auto_error=false` guard admits: no subject, no scopes, no
/// claims. FastAPI's `auto_error=False` hands the route `None` and lets it
/// decide; the route here sees a caller it knows nothing about.
fn anonymous() -> AuthenticatedUser {
{ subject: "", scopes: [], claims: Map([]), }
}
///|
/// Admit an unauthenticated caller, or refuse with `denied` — the one place
/// `auto_error` is decided, so every guard reads the flag the same way.
fn refuse(
auto_error : Bool,
denied : () -> @moonasgi.Response,
) -> Result[AuthenticatedUser, @moonasgi.Response] {
if auto_error {
Err(denied())
} else {
Ok(anonymous())
}
}
// -- bearer tokens ------------------------------------------------------------
///|
/// The guard every JWT-verified scheme shares — the OAuth2 password and
/// authorization-code flows, a plain HTTP bearer, and OpenID Connect all present
/// the same `Authorization: Bearer ` credential. Pulls the token, verifies
/// it against `secret` at `now`, then checks the scopes the route required.
fn verify_bearer(
ctx : Context,
secret : String,
scopes : SecurityScopes,
now : Int64,
auto_error : Bool,
) -> Result[AuthenticatedUser, @moonasgi.Response] {
let token = match ctx.bearer_token() {
None =>
return refuse(auto_error, () => {
unauthorized("Not authenticated", challenge=scopes.challenge())
})
Some(t) => t
}
let claims = jwt_verify(token, secret, now) catch {
_ =>
return refuse(auto_error, () => {
unauthorized(
"Could not validate credentials",
challenge=scopes.challenge(),
)
})
}
let user : AuthenticatedUser = {
subject: subject_from_claims(claims),
scopes: scopes_from_claims(claims),
claims,
}
for required in scopes.scopes {
if !user.has_scope(required) {
// The token authenticated the caller, so this is authorization: 403, not 401.
return refuse(auto_error, () => forbidden("Not enough permissions"))
}
}
Ok(user)
}
///|
/// An OAuth2 authorization-code bearer scheme (← FastAPI's
/// `OAuth2AuthorizationCodeBearer`): the `authorization_url` a browser is sent
/// to, the `token_url` the code is exchanged at, an optional `refresh_url`, and
/// the shared HS256 `secret` a presented token is verified against.
pub struct OAuth2CodeBearer {
authorization_url : String
token_url : String
refresh_url : String
secret : String
}
///|
/// Build an authorization-code bearer scheme. `refresh_url` is left out of the
/// document when empty, since a provider that offers no refresh endpoint should
/// not be described as having one.
pub fn OAuth2CodeBearer::new(
authorization_url : String,
token_url : String,
secret : String,
refresh_url? : String = "",
) -> OAuth2CodeBearer {
{ authorization_url, token_url, refresh_url, secret, }
}
///|
/// The security scheme that describes this flow, advertising `scopes`.
pub fn OAuth2CodeBearer::scheme(
self : OAuth2CodeBearer,
scopes? : Array[(String, String)] = [],
) -> SecurityScheme {
OAuth2Code(
authorization_url=self.authorization_url,
token_url=self.token_url,
refresh_url=self.refresh_url,
scopes~,
)
}
// -- API keys -----------------------------------------------------------------
///|
/// Where an API key travels (← `APIKeyHeader` / `APIKeyQuery` / `APIKeyCookie`).
pub(all) enum ApiKeyIn {
KeyHeader
KeyQuery
KeyCookie
} derive(Eq)
///|
/// An API-key scheme: the parameter `name` the key arrives under, where it
/// arrives, and the predicate that accepts a presented key. FastAPI's
/// `APIKeyHeader` only extracts and leaves the check to a dependency; a route
/// guard needs both, so the check travels with the scheme.
pub struct ApiKey {
loc : ApiKeyIn
name : String
verify : (String) -> Bool
}
///|
/// An API key read from the request header `name` (← `APIKeyHeader`).
pub fn ApiKey::header(name : String, verify : (String) -> Bool) -> ApiKey {
{ loc: KeyHeader, name, verify, }
}
///|
/// An API key read from the query parameter `name` (← `APIKeyQuery`).
pub fn ApiKey::query(name : String, verify : (String) -> Bool) -> ApiKey {
{ loc: KeyQuery, name, verify, }
}
///|
/// An API key read from the cookie `name` (← `APIKeyCookie`).
pub fn ApiKey::cookie(name : String, verify : (String) -> Bool) -> ApiKey {
{ loc: KeyCookie, name, verify, }
}
///|
/// The key this request presents for the scheme, `None` when it carries none.
pub fn ApiKey::read(self : ApiKey, ctx : Context) -> String? {
match self.loc {
KeyHeader => ctx.api_key_header(self.name)
KeyQuery => ctx.api_key_query(self.name)
KeyCookie => ctx.api_key_cookie(self.name)
}
}
///|
/// The `apiKey` scheme object this key is documented as.
pub fn ApiKey::scheme(self : ApiKey) -> SecurityScheme {
match self.loc {
KeyHeader => ApiKeyHeader(name=self.name)
KeyQuery => ApiKeyQuery(name=self.name)
KeyCookie => ApiKeyCookie(name=self.name)
}
}
///|
/// Refuse a request that presented no acceptable API key. FastAPI's API-key
/// classes answer `403`, not `401`, because an `apiKey` scheme has no
/// `WWW-Authenticate` challenge to offer and a `401` without one is malformed.
fn api_key_denied(detail : String) -> @moonasgi.Response {
forbidden(detail)
}
///|
/// The guard behind a declared API-key scheme.
fn verify_api_key(
key : ApiKey,
ctx : Context,
auto_error : Bool,
) -> Result[AuthenticatedUser, @moonasgi.Response] {
match key.read(ctx) {
None => refuse(auto_error, () => api_key_denied("Not authenticated"))
Some(presented) =>
if (key.verify)(presented) {
// The key *is* the identity here, exactly as FastAPI's `APIKeyHeader`
// dependency yields the key itself to the route.
Ok({ subject: presented, scopes: [], claims: Map([]), })
} else {
refuse(auto_error, () => {
api_key_denied("Invalid authentication credentials")
})
}
}
}
// -- HTTP basic and digest ----------------------------------------------------
///|
/// An HTTP Basic scheme (← FastAPI's `HTTPBasic`): the `realm` named in the
/// challenge, and the predicate that accepts a presented username/password.
pub struct BasicAuth {
realm : String
verify : (HttpBasicCredentials) -> Bool
}
///|
/// Build an HTTP Basic scheme. A `realm` is optional and, when given, names the
/// protection space in the challenge so a browser can tell one login from
/// another.
pub fn BasicAuth::new(
verify : (HttpBasicCredentials) -> Bool,
realm? : String = "",
) -> BasicAuth {
{ realm, verify, }
}
///|
/// The `WWW-Authenticate` value this scheme challenges with.
pub fn BasicAuth::challenge(self : BasicAuth) -> String {
auth_challenge("Basic", self.realm)
}
///|
/// An HTTP Digest scheme (← FastAPI's `HTTPDigest`, which likewise carries the
/// credential and no more): `verify` decides whether the `Digest` parameter
/// string a client sent is acceptable. Computing the RFC 7616 response digest is
/// the application's, since only it holds the password store.
pub struct DigestAuth {
realm : String
verify : (String) -> Bool
}
///|
/// Build an HTTP Digest scheme, optionally naming the protection `realm`.
pub fn DigestAuth::new(
verify : (String) -> Bool,
realm? : String = "",
) -> DigestAuth {
{ realm, verify, }
}
///|
/// The `WWW-Authenticate` value this scheme challenges with.
pub fn DigestAuth::challenge(self : DigestAuth) -> String {
auth_challenge("Digest", self.realm)
}
///|
/// A `WWW-Authenticate` value for `scheme`, carrying `realm` when there is one.
fn auth_challenge(scheme : String, realm : String) -> String {
if realm == "" {
scheme
} else {
scheme + " realm=\"" + realm + "\""
}
}
///|
/// The credential of an `Authorization: Digest ` header — the
/// comma-separated parameter list, verbatim (← FastAPI's `HTTPDigest`). `None`
/// when the header is absent or names another scheme; the scheme name is matched
/// case-insensitively, as RFC 7235 requires.
pub fn Context::digest_credentials(self : Context) -> String? {
match self.request.header("authorization") {
None => None
Some(h) => {
let trimmed = trim_spaces(h)
let prefix = "digest "
guard trimmed.length() >= prefix.length() else { None }
guard trimmed[0:prefix.length()].to_owned().to_lower() == prefix else {
None
}
Some(trim_spaces(trimmed[prefix.length():].to_owned()))
}
}
}
///|
/// The guard behind a declared HTTP Basic scheme.
fn verify_basic(
basic : BasicAuth,
ctx : Context,
auto_error : Bool,
) -> Result[AuthenticatedUser, @moonasgi.Response] {
match ctx.http_basic() {
None =>
refuse(auto_error, () => {
unauthorized("Not authenticated", challenge=basic.challenge())
})
Some(cred) =>
if (basic.verify)(cred) {
Ok({ subject: cred.username, scopes: [], claims: Map([]), })
} else {
refuse(auto_error, () => {
unauthorized(
"Incorrect username or password",
challenge=basic.challenge(),
)
})
}
}
}
///|
/// The guard behind a declared HTTP Digest scheme. Like FastAPI's `HTTPDigest`
/// it answers `403`, since a digest challenge a server cannot yet build a nonce
/// for is no challenge at all.
fn verify_digest(
digest : DigestAuth,
ctx : Context,
auto_error : Bool,
) -> Result[AuthenticatedUser, @moonasgi.Response] {
match ctx.digest_credentials() {
None => refuse(auto_error, () => forbidden("Not authenticated"))
Some(cred) =>
if (digest.verify)(cred) {
Ok(anonymous())
} else {
refuse(auto_error, () => forbidden("Invalid authentication credentials"))
}
}
}
// -- wiring a scheme onto an app ----------------------------------------------
///|
/// Declare an API-key scheme under `name` and wire it as a runtime enforcer (←
/// FastAPI's `Security(APIKeyHeader(name=...))`). The scheme appears in the
/// document as `apiKey` in the header, query or cookie the `key` names, and a
/// route requiring `name` is refused with `403` unless it presents a key the
/// scheme accepts.
pub fn App::secure_api_key(
self : App,
name : String,
key : ApiKey,
description? : String = "",
auto_error? : Bool = true,
) -> Unit {
self.security_schemes.push({ name, kind: key.scheme(), description, })
self.enforcers[name] = (ctx, _scopes, _now) => {
verify_api_key(key, ctx, auto_error)
}
}
///|
/// Declare an HTTP Basic scheme under `name` and wire it as a runtime enforcer
/// (← `Security(HTTPBasic())`). A route requiring `name` is refused with `401`
/// and a `Basic` challenge unless it presents credentials the scheme accepts.
pub fn App::secure_basic(
self : App,
name : String,
basic : BasicAuth,
description? : String = "",
auto_error? : Bool = true,
) -> Unit {
self.security_schemes.push({ name, kind: HttpBasic, description, })
self.enforcers[name] = (ctx, _scopes, _now) => {
verify_basic(basic, ctx, auto_error)
}
}
///|
/// Declare an HTTP Digest scheme under `name` and wire it as a runtime enforcer
/// (← `Security(HTTPDigest())`). A route requiring `name` is refused with `403`
/// unless it presents a `Digest` credential the scheme accepts.
pub fn App::secure_digest(
self : App,
name : String,
digest : DigestAuth,
description? : String = "",
auto_error? : Bool = true,
) -> Unit {
self.security_schemes.push({ name, kind: HttpDigest, description, })
self.enforcers[name] = (ctx, _scopes, _now) => {
verify_digest(digest, ctx, auto_error)
}
}
///|
/// Declare a plain HTTP bearer scheme under `name` and wire it as a runtime
/// enforcer (← `Security(HTTPBearer())`). The token is verified as an HS256 JWT
/// against `secret` at the app clock's time, and the route's required scopes are
/// checked against the token's — the same guard the OAuth2 flows use, without an
/// OAuth2 flow to advertise.
pub fn App::secure_bearer(
self : App,
name : String,
secret : String,
bearer_format? : String = "JWT",
description? : String = "",
auto_error? : Bool = true,
) -> Unit {
self.security_schemes.push({
name,
kind: HttpBearer(bearer_format~),
description,
})
self.enforcers[name] = (ctx, scopes, now) => {
verify_bearer(ctx, secret, scopes, now, auto_error)
}
}
///|
/// Declare an OAuth2 authorization-code scheme under `name` and wire it as a
/// runtime enforcer (← `Security(OAuth2AuthorizationCodeBearer(...))`). The
/// document describes the browser redirect and token endpoints and the
/// advertised `scopes`; the guard verifies the presented bearer token exactly as
/// the password flow's does, since by the time a request arrives the two flows
/// have produced the same credential.
pub fn App::secure_oauth2_code(
self : App,
name : String,
code : OAuth2CodeBearer,
scopes? : Array[(String, String)] = [],
description? : String = "",
auto_error? : Bool = true,
) -> Unit {
self.security_schemes.push({ name, kind: code.scheme(scopes~), description, })
self.enforcers[name] = (ctx, required, now) => {
verify_bearer(ctx, code.secret, required, now, auto_error)
}
}
///|
/// Declare an OpenID Connect scheme under `name` and wire it as a runtime
/// enforcer (← `Security(OpenIdConnect(openIdConnectUrl=...))`). `url` is the
/// discovery document a client reads every other parameter from; `secret` is
/// what the ID token presented as a bearer credential is verified against.
///
/// Swagger 2.0 has no `openIdConnect` type, so this scheme is absent from a 2.0
/// document rather than described as something it is not.
pub fn App::secure_openid(
self : App,
name : String,
url : String,
secret : String,
description? : String = "",
auto_error? : Bool = true,
) -> Unit {
self.security_schemes.push({ name, kind: OpenIdConnect(url~), description, })
self.enforcers[name] = (ctx, scopes, now) => {
verify_bearer(ctx, secret, scopes, now, auto_error)
}
}