Compare commits
14 Commits
Author | SHA1 | Date | |
---|---|---|---|
e44d476d88 | |||
7bc77ac7d7 | |||
e4c7524d3d | |||
c75c973350 | |||
3c3edacd7c | |||
9f302556f6 | |||
feeef6429c | |||
6bee730d7a | |||
9a510389d3 | |||
3b1e9b5a8d | |||
3d2f52ee1d | |||
daa78f974a | |||
04bd6aa69f | |||
f605ce6feb |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
.*.sw?
|
||||
nimcache/
|
||||
test/runner
|
||||
|
36
README.md
36
README.md
@ -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
|
@ -1,8 +1,8 @@
|
||||
# Package
|
||||
|
||||
version = "0.1.0"
|
||||
version = "0.4.7"
|
||||
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"
|
||||
srcDir = "src"
|
||||
|
||||
@ -10,10 +10,12 @@ 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"
|
||||
# from standard nimble repo
|
||||
requires @["bcrypt", "mummy", "uuids", "webby"]
|
||||
|
||||
# from https://git.jdb-software.com/jdb/nim-packages
|
||||
requires @["jwt_full >= 0.2.0"]
|
||||
|
||||
task unittest, "Runs the unit test suite.":
|
||||
exec "nim c -r test/runner"
|
||||
|
@ -1,5 +1,2 @@
|
||||
import buffoonery/apierror,
|
||||
buffoonery/apiutils,
|
||||
buffoonery/auth,
|
||||
buffoonery/jsonutils
|
||||
import buffoonery/[apierror, apiutils, auth, jsonutils]
|
||||
export apierror, apiutils, auth, jsonutils
|
||||
|
@ -1,15 +1,20 @@
|
||||
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
|
||||
|
||||
if not parent.isNil:
|
||||
result.trace &= parent.trace
|
||||
|
||||
|
||||
proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
||||
var apiError = newApiError(
|
||||
parent = nil,
|
||||
@ -19,10 +24,13 @@ proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
||||
else: msg)
|
||||
raise apiError
|
||||
|
||||
proc raiseApiError*(parent: ref Exception, respCode: HttpCode, respMsg: string, msg = "") =
|
||||
|
||||
proc raiseApiError*(respCode: HttpCode, parent: ref Exception, respMsg: string = "", msg = "") =
|
||||
var apiError = newApiError(
|
||||
parent = parent,
|
||||
respCode = respCode,
|
||||
respMsg = respMsg,
|
||||
respMsg =
|
||||
if respMsg.isEmptyOrWhitespace: parent.msg
|
||||
else: respMsg,
|
||||
msg = msg)
|
||||
raise apiError
|
||||
|
@ -1,96 +1,123 @@
|
||||
import std/json, std/logging, std/strutils, std/sequtils
|
||||
import jester, namespaced_logging
|
||||
import std/[json, jsonutils, options, sequtils, strtabs, strutils]
|
||||
import mummy, webby
|
||||
|
||||
import std/httpcore except HttpHeaders
|
||||
|
||||
import ./apierror
|
||||
|
||||
const CONTENT_TYPE_JSON* = "application/json"
|
||||
|
||||
var logNs {.threadvar.}: LoggingNamespace
|
||||
|
||||
template log(): untyped =
|
||||
if logNs.isNil: logNs = initLoggingNamespace("buffoonery/apiutils", lvlDebug)
|
||||
logNs
|
||||
|
||||
## Response Utilities
|
||||
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*(
|
||||
code: HttpCode,
|
||||
body: string = "",
|
||||
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: ""
|
||||
type
|
||||
ApiResponse*[T] = object
|
||||
details*: Option[string]
|
||||
data*: Option[T]
|
||||
nextOffset*: Option[int]
|
||||
totalItems*: Option[int]
|
||||
nextLink*: Option[string]
|
||||
prevLink*: Option[string]
|
||||
|
||||
let corsHeaders =
|
||||
if knownOrigins.contains(reqOrigin):
|
||||
|
||||
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
|
||||
|
||||
|
||||
func `$`*(r: ApiResponse): string = $(%r)
|
||||
|
||||
|
||||
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().warn "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
|
||||
@{:}
|
||||
|
||||
halt(
|
||||
code,
|
||||
cast[RawHeaders](headersToSend) & corsHeaders & @{
|
||||
"Content-Type": CONTENT_TYPE_JSON,
|
||||
"Cache-Control": "no-cache"
|
||||
},
|
||||
body
|
||||
)
|
||||
|
||||
proc makeDataBody*(data: JsonNode): string = $(%*{"details":"","data":data })
|
||||
proc makeStatusBody*(details: string): string = $(%*{"details":details})
|
||||
|
||||
template sendErrorResp*(err: ref ApiError, knownOrigins: seq[string]): void =
|
||||
log().debug err.respMsg & ( if err.msg.len > 0: ": " & err.msg else: "")
|
||||
if not err.parent.isNil: log().debug " original exception: " & err.parent.msg
|
||||
sendJsonResp(err.respCode, makeStatusBody(err.respMsg), 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().warn "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
|
||||
log().debug "Valid origins: " & knownOrigins.join(", ")
|
||||
@{:}
|
||||
if reqOrigin.isSome:
|
||||
@{"X-Invalid-Origin-Details": "Unrecognized origin '" & reqOrigin.get & "'."}
|
||||
else:
|
||||
@{"X-Invalid-Origin-Details": "Missing Origin."}
|
||||
|
||||
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),
|
||||
"")
|
||||
|
@ -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, options, sequtils, strtabs, strutils, tables, times]
|
||||
import mummy, 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
|
||||
additionalInfo*: Option[TableRef[string, JsonNode]]
|
||||
|
||||
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
|
||||
@ -25,20 +28,33 @@ type
|
||||
|
||||
issuerKeys: TableRef[string, JwkSet]
|
||||
|
||||
var logNs {.threadvar.}: LoggingNamespace
|
||||
|
||||
template log(): untyped =
|
||||
if logNs.isNil: logNs = initLoggingNamespace("buffoonery/auth", lvlDebug)
|
||||
logNs
|
||||
proc failAuth*[T](
|
||||
reason: string,
|
||||
additionalInfo: TableRef[string, T],
|
||||
parentException: ref Exception = nil) =
|
||||
## Syntactic sugar to raise an AuthError. Reason will be the exception
|
||||
## message and should be considered an internal message.
|
||||
let err = newException(AuthError, reason, parentException)
|
||||
err.additionalInfo = some(newTable[string, JsonNode]())
|
||||
for key, val in additionalInfo.pairs:
|
||||
err.additionalInfo.get[key] = %val
|
||||
raise err
|
||||
|
||||
|
||||
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 +65,7 @@ proc initApiAuthContext*(
|
||||
for k in signingKeys: validateSigningKey(k)
|
||||
|
||||
result = ApiAuthContext(
|
||||
appDomain: appDomain,
|
||||
cookiePrefix: cookiePrefix,
|
||||
validAudiences: validAudiences,
|
||||
issuer: issuer,
|
||||
@ -56,27 +73,33 @@ 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)
|
||||
var jwksKeysURI: string
|
||||
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
|
||||
jwksKeysURI = metadata.getOrFail("jwks_uri").getStr
|
||||
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"
|
||||
except Exception:
|
||||
#failAuth "unable to fetch isser signing keys"
|
||||
failAuth(
|
||||
reason = "unable to fetch isser signing keys",
|
||||
additionalInfo = newTable[string, string]({
|
||||
"openIdConfigUrl": openIdConfigUrl,
|
||||
"jwksKeysURI": jwksKeysURI }),
|
||||
parentException = getCurrentException())
|
||||
|
||||
|
||||
proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void =
|
||||
## Manually add a set of signing keys associated with a given issuer.
|
||||
@ -85,64 +108,78 @@ 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()
|
||||
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.
|
||||
##
|
||||
## 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:
|
||||
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
|
||||
if jwt.header.kid.isNone: failAuth "Missing 'kid' header."
|
||||
if jwt.claims.iss.isNone: failAuth "JWT is missing 'iss' claim."
|
||||
if jwt.header.kid.isNone: failAuth "JWT is 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 we didn't have keys for that issuer, or if we couldn't find the given
|
||||
# kid, 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"
|
||||
failAuth(
|
||||
reason = "unable to find JWT signing key",
|
||||
additionalInfo = newTable[string, string]({
|
||||
"jwtIssuer": jwtIssuer,
|
||||
"jwtKid": jwt.header.kid.get}))
|
||||
|
||||
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."
|
||||
if jwt.claims.iss.isNone: failAuth "JWT is 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]
|
||||
failAuth(
|
||||
reason = "We don't trust the JWT's issuer.",
|
||||
additionalInfo = newTable[string, JsonNode]({
|
||||
"issuer": %jwtIssuer,
|
||||
"trustedIssuers": %ctx.trustedIssuers}))
|
||||
|
||||
if jwt.header.alg.isNone: failAuth "Missing 'alg' header property."
|
||||
if jwt.header.alg.isNone: failAuth "JWT is missing 'alg' header property."
|
||||
if jwt.claims.aud.isNone: failAuth "JWT is missing 'aud' claim."
|
||||
if jwt.claims.iss.isNone: failAuth "JWT is missing 'iss' claim."
|
||||
if jwt.claims.sub.isNone: failAuth "JWT is missing 'sub' claim."
|
||||
if jwt.claims.exp.isNone: failAuth "JWT is missing or invalid 'exp' claim."
|
||||
|
||||
if jwt.claims.aud.isNone: failAuth "Missing 'aud' claim."
|
||||
|
||||
if not ctx.validAudiences.contains(jwt.claims.aud.get):
|
||||
log.debug(
|
||||
"Valid audiences: $#\ttoken audience: $#" %
|
||||
[$ctx.validAudiences, jwt.claims.aud.get])
|
||||
failAuth "JWT is not for us (invalid audience)."
|
||||
if jwt.claims["aud"].get.kind == JString:
|
||||
# If the token is for a single audience, check that it is for us.
|
||||
if not ctx.validAudiences.contains(jwt.claims.aud.get):
|
||||
failAuth "JWT is not for us (invalid audience)."
|
||||
elif jwt.claims["aud"].get.kind == JArray:
|
||||
# If the token is for multiple audiences, check that at least one is for us.
|
||||
let auds = jwt.claims["aud"].get.getElems
|
||||
if not auds.anyIt(ctx.validAudiences.contains(it.getStr)):
|
||||
failAuth "JWT is not for us (invalid audience)."
|
||||
|
||||
let signingAlgorithm = jwt.header.alg.get
|
||||
|
||||
@ -157,7 +194,8 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
|
||||
except:
|
||||
failAuth(getCurrentExceptionMsg(), getCurrentException())
|
||||
|
||||
proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
||||
|
||||
proc extractValidJwt*(ctx: ApiAuthContext, req: Request, validateCsrf = true): 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.
|
||||
@ -165,57 +203,102 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
||||
## 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.
|
||||
## intended for API consumers (not a 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.
|
||||
## 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.
|
||||
##
|
||||
## - `${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.
|
||||
## In the split-cookie mode we also check that the `csrfToken` claim in the
|
||||
## JWT payload matches the CSRF value passed via the `X-CSRF-TOKEN` header.
|
||||
## This CSRF check can be disabled by setting `validateCsrf` to `false`.
|
||||
## This option is proivded to support occasional use-cases where you want to
|
||||
## be able to serve a request using cookie auth when the client can't set
|
||||
## custom headers (e.g. a simple link from an <a> tag). Obviously, this is a
|
||||
## security risk and should only be used with caution with a full
|
||||
## understanding of the risk.
|
||||
|
||||
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
|
||||
not result.claims["csrfToken"].isSome:
|
||||
failAuth "missing CSRF token"
|
||||
if validateCsrf:
|
||||
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(""):
|
||||
failAuth "invalid CSRF token"
|
||||
if req.headers["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
|
||||
failAuth(
|
||||
reason = "invalid CSRF token",
|
||||
additionalInfo = newTable[string, string]({
|
||||
"header": req.headers["X-CSRF-TOKEN"],
|
||||
"jwt": result.claims["csrfToken"].get.getStr("")}))
|
||||
|
||||
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,9 +320,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,
|
||||
|
@ -3,21 +3,26 @@ import json, times, timeutils, uuids
|
||||
|
||||
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
|
||||
if not n.hasKey(key):
|
||||
raise newException(ValueError, "missing key '" & key & "'")
|
||||
|
||||
return n[key]
|
||||
|
||||
proc parseUUID*(n: JsonNode, key: string): UUID =
|
||||
|
||||
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)
|
||||
|
||||
proc formatMonth*(dt: DateTime): string =
|
||||
|
||||
func formatMonth*(dt: DateTime): string =
|
||||
return dt.format(MONTH_FORMAT)
|
||||
|
BIN
test/runner
BIN
test/runner
Binary file not shown.
@ -1,3 +1,3 @@
|
||||
import unittest
|
||||
|
||||
import ./tauth, ./tjson_util
|
||||
import ./tauth, ./tjsonutils
|
||||
|
Loading…
x
Reference in New Issue
Block a user