4 Commits
0.2.0 ... 0.3.0

5 changed files with 105 additions and 33 deletions

View File

@ -0,0 +1,36 @@
# Buffoonery: Tools for the [Jester Web Framework][jester]
Buffoonery is primarily an opinionated implementation of JWT-based session
management
a collection of extensions and patterns built around Jester to
facilitate the types of API services I tend to write.
## Building
### JDB Software Packages
Buffoonery depends on a number of packages which are not yet available in he
official Nim repository. The easiest way to get access to these packages is to
add a new `PackageList` to your [nimble configuration] for the [JDB Software Nim
packages repository]. The url is
`https://git.jdb-software.com/jdb/nim-packages/raw/main/packages.json`
[nimble configuration]: https://github.com/nim-lang/nimble#configuration
[JDB Software Nim packages]: https://git.jdb-software.com/jdb/nim-packages
## License
Buffoonery is available under two licenses depending on usage.
For private use, non-commercial use, or use in small enterprise (defined as any
enterprise bringing in less than $1 million in annual gross profit), Buffoonery
is available under the [MIT License][mit-license].
For commercial use in larger enterprises (more than $1 million in annual
gross profit), Buffoonery is available under the [GNU Affero General Public
License v3.0][agpl3]
[jester]: https://github.com/dom96/jester/
[mit-license]: https://mit-license.org/
[agpl3]: https://www.gnu.org/licenses/agpl-3.0.en.html

View File

@ -1,8 +1,8 @@
# Package # Package
version = "0.2.0" version = "0.3.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "JDB Software's opinionated extensions and auth layer for Jester." description = "Jonathan's opinionated extensions and auth layer for Jester."
license = "MIT" license = "MIT"
srcDir = "src" srcDir = "src"
@ -10,10 +10,12 @@ srcDir = "src"
# Dependencies # Dependencies
requires "nim >= 1.6.2" requires "nim >= 1.6.2"
# from standard nimble repo
requires @["bcrypt", "jester >= 0.5.0", "uuids"] requires @["bcrypt", "jester >= 0.5.0", "uuids"]
requires "https://git.jdb-software.com/jdb/nim-jwt-full.git" # from https://git.jdb-software.com/jdb/nim-packages
requires "https://git.jdb-software.com/jdb/nim-namespaced-logging.git" requires @["jwt_full >= 0.2.0", "namespaced_logging >= 0.3.0"]
task unittest, "Runs the unit test suite.": task unittest, "Runs the unit test suite.":
exec "nim c -r test/runner" exec "nim c -r test/runner"

View File

@ -1,4 +1,4 @@
import std/json, std/logging, std/strutils, std/sequtils import std/[json, jsonutils, logging, options, strutils, sequtils]
import jester, namespaced_logging import jester, namespaced_logging
import ./apierror import ./apierror
@ -8,10 +8,39 @@ const CONTENT_TYPE_JSON* = "application/json"
var logNs {.threadvar.}: LoggingNamespace var logNs {.threadvar.}: LoggingNamespace
template log(): untyped = template log(): untyped =
if logNs.isNil: logNs = initLoggingNamespace("buffoonery/apiutils", lvlDebug) if logNs.isNil: logNs = getLoggerForNamespace("buffoonery/apiutils", lvlDebug)
logNs logNs
## Response Utilities ## Response Utilities
## ------------------
type ApiResponse*[T] = object
details*: Option[string]
data*: Option[T]
nextOffset*: Option[int]
totalItems*: Option[int]
nextLink*: Option[string]
prevLink*: Option[string]
func initApiResponse*[T](
details = none[string](),
data: Option[T] = none[T](),
nextOffset = none[int](),
totalItems = none[int](),
nextLink = none[string](),
prevLink = none[string]()): ApiResponse[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
if r.data.isSome: result["data"] = %r.data
if r.nextOffset.isSome: result["nextOffset"] = %r.nextOffset
if r.totalItems.isSome: result["totalItems"] = %r.totalItems
if r.nextLink.isSome: result["nextLink"] = %r.nextLink
if r.prevLink.isSome: result["prevLink"] = %r.prevLink
template halt*( template halt*(
code: HttpCode, code: HttpCode,
headers: RawHeaders, headers: RawHeaders,
@ -31,8 +60,8 @@ template halt*(
template sendJsonResp*( template sendJsonResp*(
body: JsonNode, body: JsonNode,
code: HttpCode = Http200, code: HttpCode = Http200,
knownOrigins: seq[string], knownOrigins: seq[string] = @[],
headersToSend: RawHeaders) = headersToSend: RawHeaders = @{:}) =
## Immediately send a JSON response and stop processing the request. ## Immediately send a JSON response and stop processing the request.
let reqOrigin = let reqOrigin =
if headers(request).hasKey("Origin"): $(headers(request)["Origin"]) if headers(request).hasKey("Origin"): $(headers(request)["Origin"])
@ -59,13 +88,17 @@ template sendJsonResp*(
$body $body
) )
proc makeDataBody*(data: JsonNode): JsonNode = %*{"details":"","data":data} template sendResp*[T](
proc makeStatusBody*(details: string): JsonNode = %*{"details":details} resp: ApiResponse[T],
code = Http200,
allowedOrigins = newSeq[string](),
headersToSend: RawHeaders = @{:}) =
sendJsonResp(%resp, code, allowedOrigins, headersToSend)
template sendErrorResp*(err: ref ApiError, knownOrigins: seq[string]): void = template sendErrorResp*(err: ref ApiError, knownOrigins: seq[string]): void =
log().debug err.respMsg & ( if err.msg.len > 0: ": " & err.msg else: "") log().error err.respMsg & ( if err.msg.len > 0: ": " & err.msg else: "")
if not err.parent.isNil: log().debug " original exception: " & err.parent.msg if not err.parent.isNil: log().error " original exception: " & err.parent.msg
sendJsonResp(makeStatusBody(err.respMsg), err.respCode, knownOrigins, @{:}) sendJsonResp( %*{"details":err.respMsg}, err.respCode, knownOrigins)
## CORS support ## CORS support
template sendOptionsResp*( template sendOptionsResp*(

View File

@ -28,7 +28,7 @@ type
var logNs {.threadvar.}: LoggingNamespace var logNs {.threadvar.}: LoggingNamespace
template log(): untyped = template log(): untyped =
if logNs.isNil: logNs = initLoggingNamespace("buffoonery/auth", lvlDebug) if logNs.isNil: logNs = getLoggerForNamespace("buffoonery/auth", lvlDebug)
logNs logNs
proc failAuth*(reason: string, parentException: ref Exception = nil) = proc failAuth*(reason: string, parentException: ref Exception = nil) =
@ -91,6 +91,11 @@ proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void
proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gcsafe.} = proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gcsafe.} =
## Lookup the signing key for a given JWT. This method assumes that you trust ## Lookup the signing key for a given JWT. This method assumes that you trust
## the issuer named in the JWT. ## the issuer named in the JWT.
##
## If our ApiAuthContext does not contain any keys for the issuer named in
## the JWT, or if that set of keys does not contain the key id referenced in
## the JWT, we will attempt to fetch the signing keys based on the
## [OpenID Connect standard discovery mechanism](https://openid.net/specs/openid-connect-discovery-1_0.html)
try: try:
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim." if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
@ -100,23 +105,20 @@ proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gc
let jwtIssuer = jwt.claims.iss.get let jwtIssuer = jwt.claims.iss.get
# Do we already have keys for this issuer in our cache?
if ctx.issuerKeys.hasKey(jwtIssuer): if ctx.issuerKeys.hasKey(jwtIssuer):
# Do we have the key for this keyId?
let foundKeys = ctx.issuerKeys[jwtIssuer] let foundKeys = ctx.issuerKeys[jwtIssuer]
.filterIt(it.kid.isSome and it.kid.get == jwt.header.kid.get) .filterIt(it.kid.isSome and it.kid.get == jwt.header.kid.get)
if foundKeys.len == 1: return foundKeys[0] if foundKeys.len == 1: return foundKeys[0]
# If all of the above were true, we should have returned. If we reach this # If we didn't have keys for that issuer, or if we couldn't find the given
# point, we know that one of the above was false and we need to refresh our # kid, we need to refresh our cache of keys.
# cache of keys.
if allowFetch: if allowFetch:
ctx.issuerKeys[jwtIssuer] = ctx.issuerKeys[jwtIssuer] =
fetchJWKs(jwtIssuer & "/.well-known/openid-configuration") fetchJWKs(jwtIssuer & "/.well-known/openid-configuration")
return ctx.findSigningKey(jwt, false) return ctx.findSigningKey(jwt, false)
else: failAuth "unable to find JWT signing key" failAuth "unable to find JWT signing key"
except: except:
log.error "unable to find JWT signing key: " & getCurrentExceptionMsg() log.error "unable to find JWT signing key: " & getCurrentExceptionMsg()
@ -165,24 +167,22 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
## We support two authentication flows: ## We support two authentication flows:
## ##
## - Strict API via a JWT Bearer token in the Authorization header. This is ## - Strict API via a JWT Bearer token in the Authorization header. This is
## intended for API consumers (not the browser-based web-app). In this ## intended for API consumers (not a browser-based web-app). In this case,
## case, the token is validated directly. ## the token is validated directly.
## ##
## - Split JWT via two cookies: ## - Split JWT via two cookies:
## ##
## - `${cookiePrefix}-user`: Contains the JWT header and payload, but not the ## - `${cookiePrefix}-user`: Contains the JWT header and payload, but not the
## signature. This cookie is set Secure. The JWT payload contains a 30 ## signature. This cookie should be set Secure. The JWT payload should
## minute expiry (and the Max-Age is set the same) and also contains a ## have a defined expiration date (matching the Max-Age of the cookie)
## CSRF token. This cookie is accessible by the web application. ## and a CSRF token. This cookie is accessible by the web application.
##
## - `${cookiePrefix}-session`: Contains the JWT signature. This cookie is ## - `${cookiePrefix}-session`: Contains the JWT signature. This cookie is
## set Secure and HttpOnly. This serves as the session token (when the ## set Secure and HttpOnly. This serves as the session token (when the
## user closes the browser this gets unset). ## user closes the browser this gets unset).
## ##
## In this split-cookie mode, the API will also check for the presence of a ## In the split-cookie mode we also check that the `csrfToken` claim in the
## CSRF token on any mutation requests (PUT, POST, and DELETE requests). ## JWT payload matches the CSRF value passed via the `X-CSRF-TOKEN` header.
## The client must set the X-CSRF-TOKEN header with the same CSRF value
## present in the `csrfToken` claim in the JWT presented in the
## `${cookiePrefix}-user` cookie.
try: try:
if headers(req).hasKey("Authorization"): if headers(req).hasKey("Authorization"):
@ -238,6 +238,7 @@ proc createSignedJWT*(ctx: ApiAuthContext, claims: JsonNode, kid: string): JWT =
sigKey) sigKey)
proc newApiAccessToken*(ctx: ApiAuthContext, sub: string, duration = 1.hours): JWT = proc newApiAccessToken*(ctx: ApiAuthContext, sub: string, duration = 1.hours): JWT =
## Create a new JWT for API access.
result = ctx.createSignedJWT( result = ctx.createSignedJWT(
%*{ %*{
"sub": sub, "sub": sub,

View File

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