diff --git a/buffoonery.nimble b/buffoonery.nimble index dcbc085..a048225 100644 --- a/buffoonery.nimble +++ b/buffoonery.nimble @@ -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"] diff --git a/src/buffoonery/apierror.nim b/src/buffoonery/apierror.nim index 5da8bc3..88c36fa 100644 --- a/src/buffoonery/apierror.nim +++ b/src/buffoonery/apierror.nim @@ -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 diff --git a/src/buffoonery/apiutils.nim b/src/buffoonery/apiutils.nim index 214f159..1c71497 100644 --- a/src/buffoonery/apiutils.nim +++ b/src/buffoonery/apiutils.nim @@ -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,13 +16,15 @@ template log(): untyped = ## Response Utilities ## ------------------ -type ApiResponse*[T] = object - details*: Option[string] - data*: Option[T] - nextOffset*: Option[int] - totalItems*: Option[int] - nextLink*: Option[string] - prevLink*: Option[string] +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](), @@ -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), + "") diff --git a/src/buffoonery/auth.nim b/src/buffoonery/auth.nim index 988a6c7..464af31 100644 --- a/src/buffoonery/auth.nim +++ b/src/buffoonery/auth.nim @@ -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 @@ -173,7 +190,7 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT = ## - Split JWT via two cookies: ## ## - `${cookiePrefix}-user`: Contains the JWT header and payload, but not the - ## signature. This cookie should be set Secure. The JWT payload should + ## signature. This cookie should be set Secure. The JWT payload should ## have a defined expiration date (matching the Max-Age of the cookie) ## and a CSRF token. This cookie is accessible by the web application. ## @@ -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, diff --git a/src/buffoonery/jsonutils.nim b/src/buffoonery/jsonutils.nim index 1621057..f165393 100644 --- a/src/buffoonery/jsonutils.nim +++ b/src/buffoonery/jsonutils.nim @@ -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)