Initial commit.

This commit is contained in:
Jonathan Bernard 2022-01-22 18:50:29 -06:00
commit 3777e3dbbd
13 changed files with 362 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.*.sw?
nimcache/

0
README.md Normal file
View File

19
buffoonery.nimble Normal file
View File

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

7
src/buffoonery.nim Normal file
View File

@ -0,0 +1,7 @@
# This is just an example to get you started. A typical library package
# exports the main API in this file. Note that you cannot rename this file
# but you can remove it if you wish.
proc add*(x, y: int): int =
## Adds two files together.
return x + y

View File

@ -0,0 +1,28 @@
from strutils import isEmptyOrWhitespace
from httpclient 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,
respCode = respCode,
respMsg = respMsg,
msg = if msg.isEmptyOrWhitespace: respMsg
else: msg)
raise apiError
proc raiseApiError*(parent: ref Exception, respCode: HttpCode, respMsg: string, msg = "") =
var apiError = newApiError(
parent = parent,
respCode = respCode,
respMsg = respMsg,
msg = msg)
raise apiError

245
src/buffoonery/auth.nim Normal file
View File

@ -0,0 +1,245 @@
import std/httpclient, std/json, std/logging, std/options, std/sequtils,
std/strutils, std/tables, std/times
import jester, namespaced_logging
import jwt_full, jwt_full/encoding
import ./jsonutils
const SUPPORTED_SIGNATURE_ALGORITHMS = @[ HS256, RS256 ]
type
AuthError* = object of CatchableError
ApiAuthContext* = ref object
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
trustedIssuers*: seq[string] ## The list of trusted OAuth 2.0 issuers
signingKid*: string
## The key id to use for signing JWT tokens created by this API. The JWK
## representing this signing key with the private or secret key value
## must be provided either when the ApiAuthContext is initialized (see
## `initApiAuthContext` or via `addSigningKeys`
issuerKeys: TableRef[string, JwkSet]
var logNs {.threadvar.}: LoggingNamespace
template log(): untyped =
if logNs.isNil: logNs = initLoggingNamespace("buffoonery/auth", lvlDebug)
logNs
proc failAuth*(reason: string, parentException: ref Exception = nil) =
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*(
cookiePrefix: string,
validAudiences: seq[string],
issuer: string,
trustedIssuers: seq[string],
signingKid: string,
signingKeys: JwkSet): ApiAuthContext =
for k in signingKeys: validateSigningKey(k)
result = ApiAuthContext(
cookiePrefix: cookiePrefix,
validAudiences: validAudiences,
issuer: issuer,
trustedIssuers: trustedIssuers,
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)
try:
let http = newHttpClient()
# Inspect the OAuth metadata via the well-known address.
log.debug "fetchJwks: Fetching metadata from " & openIdConfigUrl
let metadata = parseJson(http.getContent(openIdConfigUrl))
# Fetch the keys from the jwk_keys URI.
let jwksKeysURI = metadata.getOrFail("jwks_uri").getStr
debug "fetchJwks: Fetching JWKs from " & jwksKeysURI
let jwksKeys = parseJson(http.getContent(jwksKeysURI))
# Parse and load the keys provided.
return initJwkSet(jwksKeys)
except:
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:
for k in keySet: validateSigningKey(k)
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()
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.
try:
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
if jwt.header.kid.isNone: failAuth "Missing 'kid' header."
if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
let jwtIssuer = jwt.claims.iss.get
# Do we already have keys for this issuer in our cache?
if ctx.issuerKeys.hasKey(jwtIssuer):
# Do we have the key for this keyId?
let foundKeys = ctx.issuerKeys[jwtIssuer]
.filterIt(it.kid.isSome and it.kid.get == jwt.header.kid.get)
if foundKeys.len == 1: return foundKeys[0]
# If all of the above were true, we should have returned. If we reach this
# point, we know that one of the above was false and we need to refresh our
# cache of keys.
if allowFetch:
ctx.issuerKeys[jwtIssuer] =
fetchJWKs(jwtIssuer & "/.well-known/openid-configuration")
return ctx.findSigningKey(jwt, false)
else: failAuth "unable to find JWT signing key"
except:
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
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
let jwtIssuer = jwt.claims.iss.get
if not ctx.trustedIssuers.contains(jwtIssuer):
failAuth "JWT is issued by $# but we only trust $#" %
[jwtIssuer, $ctx.trustedIssuers]
if jwt.header.alg.isNone: failAuth "Missing 'alg' header property."
if jwt.claims.aud.isNone or
not ctx.validAudiences.contains(jwt.claims.aud.get):
failAuth "JWT is not for us (invalid audience)."
failAuth "Issuer is trusted, but the token is not for the expected audience."
let signingAlgorithm = jwt.header.alg.get
if not SUPPORTED_SIGNATURE_ALGORITHMS.contains(signingAlgorithm):
failAuth "unacceptable signature algorithm: " & $signingAlgorithm
jwt.validate(
sigAlg = signingAlgorithm,
key = ctx.findSigningKey(jwt),
validateTimeClaims = true)
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
## exception is raised.
##
## We support two authentication flows:
##
## - 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
## case, the token is validated directly.
##
## - Split JWT via two cookies:
##
## - `${cookiePrefix}-user`: Contains the JWT header and payload, but not the
## signature. This cookie is set Secure. The JWT payload contains a 30
## minute expiry (and the Max-Age is set the same) and also contains a
## CSRF token. This cookie is accessible by the web application.
## - `${cookiePrefix}-session`: Contains the JWT signature. This cookie is
## set Secure and HttpOnly. This serves as the session token (when the
## user closes the browser this gets unset).
##
## In this split-cookie mode, the API will also check for the presence of a
## CSRF token on any mutation requests (PUT, POST, and DELETE requests).
## 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:
if headers(req).hasKey("Authorization"):
# Using a Bearer token.
result = toJWT(headers(req)["Authorization"][7..^1])
else:
# Using a user/session cookie pair
let userCookieName = ctx.cookiePrefix & "-user"
let sessionCookieName = ctx.cookiePrefix & "-session"
if not cookies(req).hasKey(userCookieName):
failAuth "missing cookie '$#'" % userCookieName
if not cookies(req).hasKey(sessionCookieName):
failAuth "missing cookie '$#'" % sessionCookieName
let userVal = cookies(req)[userCookieName]
let sessionVal = cookies(req)[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
not result.claims["csrfToken"].isSome:
failAuth "missing CSRF token"
if headers(req)["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
failAuth "invalid CSRF token"
ctx.validateJwt(result)
except:
failAuth(getCurrentExceptionMsg(), getCurrentException())
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
## chooses the signing algorithm,
let foundKeys = ctx.issuerKeys[ctx.issuer]
.filterIt(it.kid.isSome and it.kid.get == kid)
if foundKeys.len != 1:
failAuth "cannot create signed JWT, unable to find key for kid " & kid
let sigKey = foundKeys[0]
result = createSignedJwt(
initJoseHeader(%*{
"alg": sigKey.alg.get,
"typ": "JWT",
"kid": sigKey.kid.get }),
initJwtClaims(claims),
sigKey)
proc newApiAccessToken*(ctx: ApiAuthContext, sub: string, duration = 1.hours): JWT =
result = ctx.createSignedJWT(
%*{
"sub": sub,
"iss": ctx.issuer,
"iat": now().utc.toTime.toUnix.int,
"aud": ctx.issuer,
"exp": (now() + duration).utc.toTime.toUnix.int },
ctx.signingKid)

View File

@ -0,0 +1,23 @@
## JSON parsing utils
import json, times, timeutils, uuids
const MONTH_FORMAT* = "YYYY-MM"
proc getOrFail*(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or raise an exception
if not n.hasKey(key):
raise newException(ValueError, "missing key '" & key & "'")
return n[key]
proc 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)
proc formatMonth*(dt: DateTime): string =
return dt.format(MONTH_FORMAT)

View File

@ -0,0 +1,10 @@
import logging, os, sequtils, strutils
proc enableConsoleLoggingWithEnvVar*(envVar = "DEBUG"): void =
let val = getEnv(envVar, "false").toLower()
if ("true".startsWith(val) or
"yes".startsWith(val) or
"on".startsWith(val) or
val == "1") and
not logging.getHandlers().anyIt(typeof(it) is ConsoleLogger):
logging.addHandler(newConsoleLogger(levelThreshold = lvlDebug))

2
test/config.nims Normal file
View File

@ -0,0 +1,2 @@
switch("path", "../src")
switch("verbosity", "0")

BIN
test/runner Executable file

Binary file not shown.

3
test/runner.nim Normal file
View File

@ -0,0 +1,3 @@
import unittest
import ./tauth, ./tjson_util

0
test/tauth.nim Normal file
View File

23
test/tjsonutils.nim Normal file
View File

@ -0,0 +1,23 @@
import json, times, unittest, uuids
import buffoonery/jsonutils
suite "jsonutils":
let n = %*{
"numVal": 12345,
"strVal": "Test string",
"uuid": $genUUID(),
"isoDate": "2021-07-19T16:20:32+00:00",
"month": "2021-07"
}
test "getOrFail":
check:
n.getOrFail("strVal").getStr == "Test string"
n.getOrFail("numVal").getInt == 12345
expect(ValueError):
discard n.getOrFail("missingVal")
test "parseMonth":
check n.parseMonth("month") == "2021-07".parse(MONTH_FORMAT)