Refactor to base on mummy instead of jester.

This commit is contained in:
Jonathan Bernard 2025-01-01 11:22:48 -06:00
parent 9a510389d3
commit 6e6351429d
5 changed files with 180 additions and 113 deletions

View File

@ -1,6 +1,6 @@
# Package
version = "0.3.0"
version = "0.4.0"
author = "Jonathan Bernard"
description = "Jonathan's opinionated extensions and auth layer for Jester."
license = "MIT"
@ -12,7 +12,7 @@ srcDir = "src"
requires "nim >= 1.6.2"
# from standard nimble repo
requires @["bcrypt", "jester >= 0.5.0", "uuids"]
requires @["bcrypt", "mummy", "uuids", "webby"]
# from https://git.jdb-software.com/jdb/nim-packages
requires @["jwt_full >= 0.2.0", "namespaced_logging >= 0.3.0"]

View File

@ -1,15 +1,17 @@
from strutils import isEmptyOrWhitespace
from httpclient import HttpCode
from httpcore import HttpCode
type ApiError* = object of CatchableError
respMsg*: string
respCode*: HttpCode
proc newApiError*(parent: ref Exception = nil, respCode: HttpCode, respMsg: string, msg = ""): ref ApiError =
result = newException(ApiError, msg, parent)
result.respCode = respCode
result.respMsg = respMsg
proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
var apiError = newApiError(
parent = nil,
@ -19,10 +21,13 @@ proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
else: msg)
raise apiError
proc raiseApiError*(parent: ref Exception, respCode: HttpCode, respMsg: string, msg = "") =
proc raiseApiError*(parent: ref Exception, respCode: HttpCode, respMsg: string = "", msg = "") =
var apiError = newApiError(
parent = parent,
respCode = respCode,
respMsg = respMsg,
respMsg =
if respMsg.isEmptyOrWhitespace: parent.msg
else: respMsg,
msg = msg)
raise apiError

View File

@ -1,5 +1,7 @@
import std/[json, jsonutils, logging, options, strutils, sequtils]
import jester, namespaced_logging
import std/[json, jsonutils, logging, options, sequtils, strtabs, strutils]
import mummy, namespaced_logging, webby
import std/httpcore except HttpHeaders
import ./apierror
@ -14,7 +16,8 @@ template log(): untyped =
## Response Utilities
## ------------------
type ApiResponse*[T] = object
type
ApiResponse*[T] = object
details*: Option[string]
data*: Option[T]
nextOffset*: Option[int]
@ -22,6 +25,7 @@ type ApiResponse*[T] = object
nextLink*: Option[string]
prevLink*: Option[string]
func initApiResponse*[T](
details = none[string](),
data: Option[T] = none[T](),
@ -32,6 +36,7 @@ func initApiResponse*[T](
ApiResponse[T](details: details, data: data, nextOffset: nextOffset,
totalItems: totalItems, nextLink: nextLink, prevLink: prevLink)
func `%`*(r: ApiResponse): JsonNode =
result = newJObject()
if r.details.isSome: result["details"] = %r.details
@ -41,89 +46,86 @@ func `%`*(r: ApiResponse): JsonNode =
if r.nextLink.isSome: result["nextLink"] = %r.nextLink
if r.prevLink.isSome: result["prevLink"] = %r.prevLink
template halt*(
code: HttpCode,
headers: RawHeaders,
content: string) =
## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current
## route.
bind TCActionSend, newHttpHeaders
result[0] = CallbackAction.TCActionSend
result[1] = code
result[2] = if isSome(result[2]): some(result[2].get & headers)
else: some(headers)
result[3] = content
result.matched = true
break allRoutes
template sendJsonResp*(
body: JsonNode,
code: HttpCode = Http200,
knownOrigins: seq[string] = @[],
headersToSend: RawHeaders = @{:}) =
## Immediately send a JSON response and stop processing the request.
let reqOrigin =
if headers(request).hasKey("Origin"): $(headers(request)["Origin"])
else: ""
func `$`*(r: ApiResponse): string = $(%r)
let corsHeaders =
if knownOrigins.contains(reqOrigin):
proc makeCorsHeaders*(
allowedMethods: seq[string],
allowedOrigins: seq[string],
reqOrigin = none[string]()): HttpHeaders =
result =
if reqOrigin.isSome and allowedOrigins.contains(reqOrigin.get):
@{
"Access-Control-Allow-Origin": reqOrigin,
"Access-Control-Allow-Origin": reqOrigin.get,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": $(reqMethod(request)),
"Access-Control-Allow-Headers": "Authorization,X-CSRF-TOKEN"
}
else:
log().debug "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
@{:}
halt(
code,
cast[RawHeaders](headersToSend) & corsHeaders & @{
"Content-Type": CONTENT_TYPE_JSON,
"Cache-Control": "no-cache"
},
$body
)
template sendResp*[T](
resp: ApiResponse[T],
code = Http200,
allowedOrigins = newSeq[string](),
headersToSend: RawHeaders = @{:}) =
sendJsonResp(%resp, code, allowedOrigins, headersToSend)
template sendErrorResp*(err: ref ApiError, knownOrigins: seq[string]): void =
log().error err.respMsg & ( if err.msg.len > 0: ": " & err.msg else: "")
if not err.parent.isNil: log().error " original exception: " & err.parent.msg
sendJsonResp( %*{"details":err.respMsg}, err.respCode, knownOrigins)
## CORS support
template sendOptionsResp*(
allowedMethods: seq[HttpMethod],
knownOrigins: seq[string]) =
let reqOrigin =
if headers(request).hasKey("Origin"): $(headers(request)["Origin"])
else: ""
let corsHeaders =
if knownOrigins.contains(reqOrigin):
@{
"Access-Control-Allow-Origin": reqOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": allowedMethods.mapIt($it).join(", "),
"Access-Control-Allow-Methods": allowedMethods.join(", "),
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-CSRF-TOKEN"
}
else:
log().debug "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
log().debug "Valid origins: " & knownOrigins.join(", ")
if reqOrigin.isSome:
log().debug "Unrecognized Origin '" & reqOrigin.get & "', excluding CORS headers."
else:
log().debug "No Origin supplied, excluding CORS headers."
log().debug "Valid origins: " & allowedOrigins.join(", ")
@{:}
halt(
Http200,
corsHeaders,
""
)
proc makeCorsHeaders*(
allowedMethods: seq[HttpMethod],
allowedOrigins: seq[string],
reqOrigin = none[string]()): HttpHeaders =
makeCorsHeaders(allowedMethods.mapIt($it), allowedOrigins, reqOrigin )
func origin*(req: Request): Option[string] =
if req.headers.contains("Origin"): some(req.headers["Origin"])
else: none[string]()
proc respondWithRawJson*(
req: Request,
body: JsonNode,
code = Http200,
allowedOrigins = newSeq[string](),
headersToSend: HttpHeaders = @{:}) =
## Immediately send a JSON response and stop processing the request.
var headers =
headersToSend &
makeCorsHeaders(@[req.httpMethod], allowedOrigins, req.origin) &
@[("Content-Type", CONTENT_TYPE_JSON),
("Cache-Control", "no-cache")]
req.respond(code.ord, headers, $body)
proc respond*[T](
req: Request,
resp: ApiResponse[T],
code = Http200,
allowedOrigins = newSeq[string](),
headersToSend: HttpHeaders = @{:}) =
req.respondWithRawJson(%resp, code, allowedOrigins, headersToSend)
proc respondWithData*[T](
req: Request,
data: T,
code = Http200,
allowedOrigins = newSeq[string](),
headersToSend: HttpHeaders = @{:}) =
req.respond(initApiResponse[T](data = some(data)),
code, allowedOrigins, headersToSend)
proc respondToOptions*(
req: Request,
allowedMethods: seq[HttpMethod],
allowedOrigins: seq[string]) =
req.respond(
Http200.ord,
makeCorsHeaders(allowedMethods, allowedOrigins, req.origin),
"")

View File

@ -1,17 +1,20 @@
import std/httpclient, std/json, std/logging, std/options, std/sequtils,
std/strutils, std/tables, std/times
import jester, namespaced_logging
import std/[cookies, json, logging, options, sequtils, strtabs,
strutils, tables, times]
import mummy, namespaced_logging, uuids, webby
import std/httpclient except HttpHeaders
import jwt_full, jwt_full/encoding
import ./jsonutils
import ./apiutils, ./jsonutils
const SUPPORTED_SIGNATURE_ALGORITHMS = @[ HS256, RS256 ]
type
AuthError* = object of CatchableError
ApiAuthContext* = ref object
appDomain*: string ## Application domain for session cookies
cookiePrefix*: string ## Prefix for the user and session cookies
validAudiences*: seq[string] ## Expected audience values for for `aud` JWT check
issuer*: string ## The JWT issuer for tokens created by this API
@ -31,14 +34,20 @@ template log(): untyped =
if logNs.isNil: logNs = getLoggerForNamespace("buffoonery/auth", lvlDebug)
logNs
proc failAuth*(reason: string, parentException: ref Exception = nil) =
## Syntactic sugar to raise an AuthError. Reason will be the exception
## message and should be considered an internal message.
raise newException(AuthError, reason, parentException)
proc validateSigningKey(k: JWK): void =
if k.alg.isNone: failAuth "JWK is missing 'alg'"
if k.kid.isNone: failAuth "JWK is missing 'kid'"
proc initApiAuthContext*(
appDomain: string,
cookiePrefix: string,
validAudiences: seq[string],
issuer: string,
@ -49,6 +58,7 @@ proc initApiAuthContext*(
for k in signingKeys: validateSigningKey(k)
result = ApiAuthContext(
appDomain: appDomain,
cookiePrefix: cookiePrefix,
validAudiences: validAudiences,
issuer: issuer,
@ -56,6 +66,7 @@ proc initApiAuthContext*(
signingKid: signingKid,
issuerKeys: newTable[string, JwkSet]([(issuer, signingKeys)]))
proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
## Fetch signing keys for an OAuth issuer. `openIdConfigUrl` is expected to
## be a well-known URL (ISSUER_BASE/.well-known/openid-configuration)
@ -63,7 +74,7 @@ proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
let http = newHttpClient()
# Inspect the OAuth metadata via the well-known address.
log.debug "fetchJwks: Fetching metadata from " & openIdConfigUrl
log().debug "fetchJwks: Fetching metadata from " & openIdConfigUrl
let metadata = parseJson(http.getContent(openIdConfigUrl))
# Fetch the keys from the jwk_keys URI.
@ -75,9 +86,10 @@ proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
return initJwkSet(jwksKeys)
except:
log.error "unable to fetch issuer signing keys: " & getCurrentExceptionMsg()
log().error "unable to fetch issuer signing keys: " & getCurrentExceptionMsg()
failAuth "unable to fetch isser signing keys"
proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void =
## Manually add a set of signing keys associated with a given issuer.
try:
@ -85,9 +97,10 @@ proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void
if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
ctx.issuerKeys[issuer] = keySet
except:
log.error "unable to add a set of signing keys: " & getCurrentExceptionMsg()
log().error "unable to add a set of signing keys: " & getCurrentExceptionMsg()
raise getCurrentException()
proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gcsafe.} =
## Lookup the signing key for a given JWT. This method assumes that you trust
## the issuer named in the JWT.
@ -121,14 +134,15 @@ proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gc
failAuth "unable to find JWT signing key"
except:
log.error "unable to find JWT signing key: " & getCurrentExceptionMsg()
log().error "unable to find JWT signing key: " & getCurrentExceptionMsg()
failAuth("unable to find JWT signing key", getCurrentException())
proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
## Given a JWT, validate that it is a well-formed JWT, validate the issuer's
## signature on the token, and validate all the claims that it preesnts.
try:
log.debug "Validating JWT: " & $jwt
log().debug "Validating JWT: " & $jwt
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
let jwtIssuer = jwt.claims.iss.get
@ -137,11 +151,13 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
[jwtIssuer, $ctx.trustedIssuers]
if jwt.header.alg.isNone: failAuth "Missing 'alg' header property."
if jwt.claims.aud.isNone: failAuth "Missing 'aud' claim."
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
if jwt.claims.sub.isNone: failAuth "Missing 'sub' claim."
if jwt.claims.exp.isNone: failAuth "Missing or invalid 'exp' claim."
if not ctx.validAudiences.contains(jwt.claims.aud.get):
log.debug(
log().debug(
"Valid audiences: $#\ttoken audience: $#" %
[$ctx.validAudiences, jwt.claims.aud.get])
failAuth "JWT is not for us (invalid audience)."
@ -159,6 +175,7 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
except:
failAuth(getCurrentExceptionMsg(), getCurrentException())
proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
## Extracts a valid JWT representing the user's authentication and
## authorization details, if present. If there are no valid credentials an
@ -185,37 +202,73 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
## JWT payload matches the CSRF value passed via the `X-CSRF-TOKEN` header.
try:
if headers(req).hasKey("Authorization"):
if req.headers.contains("Authorization"):
# Using a Bearer token.
result = toJWT(headers(req)["Authorization"][7..^1])
result = toJWT(req.headers["Authorization"][7..^1])
else:
elif req.headers.contains("Cookie"):
# Using a user/session cookie pair
let userCookieName = ctx.cookiePrefix & "-user"
let sessionCookieName = ctx.cookiePrefix & "-session"
if not cookies(req).hasKey(userCookieName):
let cookies = parseCookies(req.headers["Cookie"])
if not cookies.contains(userCookieName):
failAuth "missing cookie '$#'" % userCookieName
if not cookies(req).hasKey(sessionCookieName):
if not cookies.contains(sessionCookieName):
failAuth "missing cookie '$#'" % sessionCookieName
let userVal = cookies(req)[userCookieName]
let sessionVal = cookies(req)[sessionCookieName]
let userVal = cookies[userCookieName]
let sessionVal = cookies[sessionCookieName]
result = toJWT(userVal & "." & sessionVal)
# Because this is a web session, check that the CSRF is present and
# matches.
if not headers(req).hasKey("X-CSRF-TOKEN") or
if not req.headers.contains("X-CSRF-TOKEN") or
not result.claims["csrfToken"].isSome:
failAuth "missing CSRF token"
if headers(req)["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
if req.headers["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
failAuth "invalid CSRF token"
ctx.validateJwt(result)
else: failAuth "no auth token, no Authorization or Cookie headers"
ctx.validateJWT(result)
except:
failAuth(getCurrentExceptionMsg(), getCurrentException())
proc createSessionCookies*(ctx: ApiAuthContext, jwt: JWT): HttpHeaders =
# Split the token to get the user and session cookie values.
let strToken = $jwt
let splitToken = strToken.rsplit('.', 1)
# User cookie (accessible by the application)
let userCookie = setCookie(
key = ctx.cookiePrefix & "-user",
value = splitToken[0],
domain = ctx.appDomain,
expires = jwt.claims.exp.get.utc,
httpOnly = false,
path = "/",
sameSite = SameSite.Strict,
secure = true)
# Session cookie (used by the API)
let sessionCookie = setCookie(
key = ctx.cookiePrefix & "-session",
value = splitToken[1],
domain = ctx.appDomain,
httpOnly = true,
path = "/",
sameSite = SameSite.Strict,
secure = true)
for c in [userCookie, sessionCookie]:
let parts = c.split(": ")
result &= [(parts[0], parts[1])]
proc createSignedJWT*(ctx: ApiAuthContext, claims: JsonNode, kid: string): JWT =
## Given a set of claims, create a JWT using the given key for our issuer
## (as defined in the ApiAuthContext). This is an opinionated method that
@ -237,10 +290,12 @@ proc createSignedJWT*(ctx: ApiAuthContext, claims: JsonNode, kid: string): JWT =
initJwtClaims(claims),
sigKey)
proc newApiAccessToken*(ctx: ApiAuthContext, sub: string, duration = 1.hours): JWT =
## Create a new JWT for API access.
result = ctx.createSignedJWT(
%*{
"sid": $genUUID(),
"sub": sub,
"iss": ctx.issuer,
"iat": now().utc.toTime.toUnix.int,

View File

@ -3,6 +3,7 @@ import json, times, timeutils, uuids
const MONTH_FORMAT* = "YYYY-MM"
func getOrFail*(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or raise an exception
if not n.hasKey(key):
@ -10,14 +11,18 @@ func getOrFail*(n: JsonNode, key: string): JsonNode =
return n[key]
func parseUUID*(n: JsonNode, key: string): UUID =
return parseUUID(n.getOrFail(key).getStr)
proc parseIso8601*(n: JsonNode, key: string): DateTime =
return parseIso8601(n.getOrFail(key).getStr)
proc parseMonth*(n: JsonNode, key: string): DateTime =
return parse(n.getOrFail(key).getStr, MONTH_FORMAT)
func formatMonth*(dt: DateTime): string =
return dt.format(MONTH_FORMAT)