commit 3777e3dbbd60a35d65326c3741ccfd053c77b5dd Author: Jonathan Bernard Date: Sat Jan 22 18:50:29 2022 -0600 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27b4ec6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.*.sw? +nimcache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/buffoonery.nimble b/buffoonery.nimble new file mode 100644 index 0000000..7504e68 --- /dev/null +++ b/buffoonery.nimble @@ -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" diff --git a/src/buffoonery.nim b/src/buffoonery.nim new file mode 100644 index 0000000..4b2a270 --- /dev/null +++ b/src/buffoonery.nim @@ -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 diff --git a/src/buffoonery/apierror.nim b/src/buffoonery/apierror.nim new file mode 100644 index 0000000..5da8bc3 --- /dev/null +++ b/src/buffoonery/apierror.nim @@ -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 diff --git a/src/buffoonery/auth.nim b/src/buffoonery/auth.nim new file mode 100644 index 0000000..ce042b5 --- /dev/null +++ b/src/buffoonery/auth.nim @@ -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) diff --git a/src/buffoonery/jsonutils.nim b/src/buffoonery/jsonutils.nim new file mode 100644 index 0000000..08c2c2d --- /dev/null +++ b/src/buffoonery/jsonutils.nim @@ -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) diff --git a/src/buffoonery/testing.nim b/src/buffoonery/testing.nim new file mode 100644 index 0000000..b7af33c --- /dev/null +++ b/src/buffoonery/testing.nim @@ -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)) diff --git a/test/config.nims b/test/config.nims new file mode 100644 index 0000000..cd13e0a --- /dev/null +++ b/test/config.nims @@ -0,0 +1,2 @@ +switch("path", "../src") +switch("verbosity", "0") diff --git a/test/runner b/test/runner new file mode 100755 index 0000000..b2dab07 Binary files /dev/null and b/test/runner differ diff --git a/test/runner.nim b/test/runner.nim new file mode 100644 index 0000000..730b9ad --- /dev/null +++ b/test/runner.nim @@ -0,0 +1,3 @@ +import unittest + +import ./tauth, ./tjson_util diff --git a/test/tauth.nim b/test/tauth.nim new file mode 100644 index 0000000..e69de29 diff --git a/test/tjsonutils.nim b/test/tjsonutils.nim new file mode 100644 index 0000000..50752a2 --- /dev/null +++ b/test/tjsonutils.nim @@ -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)