Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
3d2f52ee1d | |||
daa78f974a | |||
04bd6aa69f | |||
f605ce6feb |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
.*.sw?
|
.*.sw?
|
||||||
nimcache/
|
nimcache/
|
||||||
|
test/runner
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Package
|
# Package
|
||||||
|
|
||||||
version = "0.1.0"
|
version = "0.2.2"
|
||||||
author = "Jonathan Bernard"
|
author = "Jonathan Bernard"
|
||||||
description = "JDB Software's opinionated extensions and auth layer for Jester."
|
description = "JDB Software's opinionated extensions and auth layer for Jester."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -12,8 +12,8 @@ srcDir = "src"
|
|||||||
requires "nim >= 1.6.2"
|
requires "nim >= 1.6.2"
|
||||||
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"
|
requires "https://git.jdb-software.com/jdb/nim-jwt-full.git >= 0.2.0"
|
||||||
requires "https://git.jdb-software.com/jdb/nim-namespaced-logging.git"
|
requires "https://git.jdb-software.com/jdb/nim-namespaced-logging.git >= 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"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import std/json, std/logging, std/strutils, std/sequtils
|
import std/json, std/logging, std/options, std/strutils, std/sequtils
|
||||||
import jester, namespaced_logging
|
import jester, namespaced_logging
|
||||||
|
|
||||||
import ./apierror
|
import ./apierror
|
||||||
@ -8,7 +8,7 @@ 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
|
||||||
@ -29,8 +29,8 @@ template halt*(
|
|||||||
break allRoutes
|
break allRoutes
|
||||||
|
|
||||||
template sendJsonResp*(
|
template sendJsonResp*(
|
||||||
code: HttpCode,
|
body: JsonNode,
|
||||||
body: string = "",
|
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.
|
||||||
@ -47,7 +47,7 @@ template sendJsonResp*(
|
|||||||
"Access-Control-Allow-Headers": "Authorization,X-CSRF-TOKEN"
|
"Access-Control-Allow-Headers": "Authorization,X-CSRF-TOKEN"
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
log().warn "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
|
log().debug "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
|
||||||
@{:}
|
@{:}
|
||||||
|
|
||||||
halt(
|
halt(
|
||||||
@ -56,16 +56,29 @@ template sendJsonResp*(
|
|||||||
"Content-Type": CONTENT_TYPE_JSON,
|
"Content-Type": CONTENT_TYPE_JSON,
|
||||||
"Cache-Control": "no-cache"
|
"Cache-Control": "no-cache"
|
||||||
},
|
},
|
||||||
body
|
$body
|
||||||
)
|
)
|
||||||
|
|
||||||
proc makeDataBody*(data: JsonNode): string = $(%*{"details":"","data":data })
|
proc makeDataBody*(
|
||||||
proc makeStatusBody*(details: string): string = $(%*{"details":details})
|
data: JsonNode,
|
||||||
|
nextOffset = none[int](),
|
||||||
|
totalItems = none[int](),
|
||||||
|
nextLink = none[string](),
|
||||||
|
prevLink = none[string]()): JsonNode =
|
||||||
|
|
||||||
|
result = %*{"details":"","data":data}
|
||||||
|
|
||||||
|
if nextOffset.isSome: result["nextOffset"] = %nextOffset.get
|
||||||
|
if totalItems.isSome: result["totalItems"] = %totalItems.get
|
||||||
|
if nextLink.isSome: result["next"] = %nextLink.get
|
||||||
|
if prevLink.isSome: result["prev"] = %prevLink.get
|
||||||
|
|
||||||
|
proc makeStatusBody*(details: string): JsonNode = %*{"details":details}
|
||||||
|
|
||||||
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().debug 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().debug " original exception: " & err.parent.msg
|
||||||
sendJsonResp(err.respCode, makeStatusBody(err.respMsg), knownOrigins, @{:})
|
sendJsonResp(makeStatusBody(err.respMsg), err.respCode, knownOrigins, @{:})
|
||||||
|
|
||||||
## CORS support
|
## CORS support
|
||||||
template sendOptionsResp*(
|
template sendOptionsResp*(
|
||||||
@ -85,7 +98,7 @@ template sendOptionsResp*(
|
|||||||
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-CSRF-TOKEN"
|
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-CSRF-TOKEN"
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
log().warn "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
|
log().debug "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
|
||||||
log().debug "Valid origins: " & knownOrigins.join(", ")
|
log().debug "Valid origins: " & knownOrigins.join(", ")
|
||||||
@{:}
|
@{:}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
BIN
test/runner
BIN
test/runner
Binary file not shown.
@ -1,3 +1,3 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import ./tauth, ./tjson_util
|
import ./tauth, ./tjsonutils
|
||||||
|
Reference in New Issue
Block a user