Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
c6d02d7db7 | |||
e44d476d88 | |||
7bc77ac7d7 | |||
e4c7524d3d | |||
c75c973350 | |||
3c3edacd7c | |||
9f302556f6 | |||
feeef6429c |
@ -1,6 +1,6 @@
|
|||||||
# Package
|
# Package
|
||||||
|
|
||||||
version = "0.4.0"
|
version = "0.4.8"
|
||||||
author = "Jonathan Bernard"
|
author = "Jonathan Bernard"
|
||||||
description = "Jonathan's opinionated extensions and auth layer for Jester."
|
description = "Jonathan's opinionated extensions and auth layer for Jester."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -15,7 +15,7 @@ requires "nim >= 1.6.2"
|
|||||||
requires @["bcrypt", "mummy", "uuids", "webby"]
|
requires @["bcrypt", "mummy", "uuids", "webby"]
|
||||||
|
|
||||||
# from https://git.jdb-software.com/jdb/nim-packages
|
# from https://git.jdb-software.com/jdb/nim-packages
|
||||||
requires @["jwt_full >= 0.2.0", "namespaced_logging >= 0.3.0"]
|
requires @["jwt_full >= 0.2.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,5 +1,2 @@
|
|||||||
import buffoonery/apierror,
|
import buffoonery/[apierror, apiutils, auth, jsonutils]
|
||||||
buffoonery/apiutils,
|
|
||||||
buffoonery/auth,
|
|
||||||
buffoonery/jsonutils
|
|
||||||
export apierror, apiutils, auth, jsonutils
|
export apierror, apiutils, auth, jsonutils
|
||||||
|
@ -11,6 +11,9 @@ proc newApiError*(parent: ref Exception = nil, respCode: HttpCode, respMsg: stri
|
|||||||
result.respCode = respCode
|
result.respCode = respCode
|
||||||
result.respMsg = respMsg
|
result.respMsg = respMsg
|
||||||
|
|
||||||
|
if not parent.isNil:
|
||||||
|
result.trace &= parent.trace
|
||||||
|
|
||||||
|
|
||||||
proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
||||||
var apiError = newApiError(
|
var apiError = newApiError(
|
||||||
@ -22,7 +25,7 @@ proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
|||||||
raise apiError
|
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(
|
var apiError = newApiError(
|
||||||
parent = parent,
|
parent = parent,
|
||||||
respCode = respCode,
|
respCode = respCode,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import std/[json, jsonutils, logging, options, sequtils, strtabs, strutils]
|
import std/[json, jsonutils, options, sequtils, strtabs, strutils]
|
||||||
import mummy, namespaced_logging, webby
|
import mummy, webby
|
||||||
|
|
||||||
import std/httpcore except HttpHeaders
|
import std/httpcore except HttpHeaders
|
||||||
|
|
||||||
@ -7,12 +7,6 @@ import ./apierror
|
|||||||
|
|
||||||
const CONTENT_TYPE_JSON* = "application/json"
|
const CONTENT_TYPE_JSON* = "application/json"
|
||||||
|
|
||||||
var logNs {.threadvar.}: LoggingNamespace
|
|
||||||
|
|
||||||
template log(): untyped =
|
|
||||||
if logNs.isNil: logNs = getLoggerForNamespace("buffoonery/apiutils", lvlDebug)
|
|
||||||
logNs
|
|
||||||
|
|
||||||
## Response Utilities
|
## Response Utilities
|
||||||
## ------------------
|
## ------------------
|
||||||
|
|
||||||
@ -53,6 +47,7 @@ func `$`*(r: ApiResponse): string = $(%r)
|
|||||||
proc makeCorsHeaders*(
|
proc makeCorsHeaders*(
|
||||||
allowedMethods: seq[string],
|
allowedMethods: seq[string],
|
||||||
allowedOrigins: seq[string],
|
allowedOrigins: seq[string],
|
||||||
|
allowedHeaders: Option[seq[string]],
|
||||||
reqOrigin = none[string]()): HttpHeaders =
|
reqOrigin = none[string]()): HttpHeaders =
|
||||||
|
|
||||||
result =
|
result =
|
||||||
@ -61,22 +56,33 @@ proc makeCorsHeaders*(
|
|||||||
"Access-Control-Allow-Origin": reqOrigin.get,
|
"Access-Control-Allow-Origin": reqOrigin.get,
|
||||||
"Access-Control-Allow-Credentials": "true",
|
"Access-Control-Allow-Credentials": "true",
|
||||||
"Access-Control-Allow-Methods": allowedMethods.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"
|
"Access-Control-Allow-Headers":
|
||||||
|
if allowedHeaders.isSome: allowedHeaders.get.join(",")
|
||||||
|
else:
|
||||||
|
"DNT,User-Agent,X-Requested-With,If-Modified-Since," &
|
||||||
|
"Cache-Control,Content-Type,Range,Authorization,X-CSRF-TOKEN," &
|
||||||
|
"traceparent,tracestate,X-Request-ID,X-Correlation-ID",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
if reqOrigin.isSome:
|
if reqOrigin.isSome:
|
||||||
log().debug "Unrecognized Origin '" & reqOrigin.get & "', excluding CORS headers."
|
@{"X-Invalid-Origin-Details": "Unrecognized origin '" & reqOrigin.get & "'."}
|
||||||
else:
|
else:
|
||||||
log().debug "No Origin supplied, excluding CORS headers."
|
@{"X-Invalid-Origin-Details": "Missing Origin."}
|
||||||
log().debug "Valid origins: " & allowedOrigins.join(", ")
|
|
||||||
@{:}
|
|
||||||
|
|
||||||
|
|
||||||
proc makeCorsHeaders*(
|
proc makeCorsHeaders*(
|
||||||
allowedMethods: seq[HttpMethod],
|
allowedMethods: seq[HttpMethod],
|
||||||
allowedOrigins: seq[string],
|
allowedOrigins: seq[string],
|
||||||
|
allowedHeaders: Option[seq[string]],
|
||||||
reqOrigin = none[string]()): HttpHeaders =
|
reqOrigin = none[string]()): HttpHeaders =
|
||||||
makeCorsHeaders(allowedMethods.mapIt($it), allowedOrigins, reqOrigin )
|
makeCorsHeaders(allowedMethods.mapIt($it), allowedOrigins, allowedHeaders, reqOrigin )
|
||||||
|
|
||||||
|
|
||||||
|
proc makeCorsHeaders*[T: HttpMethod or string](
|
||||||
|
allowedMethods: seq[T],
|
||||||
|
allowedOrigins: seq[string],
|
||||||
|
reqOrigin = none[string]()): HttpHeaders =
|
||||||
|
makeCorsHeaders(allowedMethods, allowedOrigins, none[seq[string]](), reqOrigin )
|
||||||
|
|
||||||
|
|
||||||
func origin*(req: Request): Option[string] =
|
func origin*(req: Request): Option[string] =
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import std/[cookies, json, logging, options, sequtils, strtabs,
|
import std/[cookies, json, options, sequtils, strtabs, strutils, tables, times]
|
||||||
strutils, tables, times]
|
import mummy, uuids, webby
|
||||||
import mummy, namespaced_logging, uuids, webby
|
|
||||||
import std/httpclient except HttpHeaders
|
import std/httpclient except HttpHeaders
|
||||||
|
|
||||||
import jwt_full, jwt_full/encoding
|
import jwt_full, jwt_full/encoding
|
||||||
|
|
||||||
import ./apiutils, ./jsonutils
|
import ./[apiutils,jsonutils]
|
||||||
|
|
||||||
const SUPPORTED_SIGNATURE_ALGORITHMS = @[ HS256, RS256 ]
|
const SUPPORTED_SIGNATURE_ALGORITHMS = @[ HS256, RS256 ]
|
||||||
|
|
||||||
|
|
||||||
type
|
type
|
||||||
AuthError* = object of CatchableError
|
AuthError* = object of CatchableError
|
||||||
|
additionalInfo*: Option[TableRef[string, JsonNode]]
|
||||||
|
|
||||||
ApiAuthContext* = ref object
|
ApiAuthContext* = ref object
|
||||||
appDomain*: string ## Application domain for session cookies
|
appDomain*: string ## Application domain for session cookies
|
||||||
@ -28,11 +28,18 @@ type
|
|||||||
|
|
||||||
issuerKeys: TableRef[string, JwkSet]
|
issuerKeys: TableRef[string, JwkSet]
|
||||||
|
|
||||||
var logNs {.threadvar.}: LoggingNamespace
|
|
||||||
|
|
||||||
template log(): untyped =
|
proc failAuth*[T](
|
||||||
if logNs.isNil: logNs = getLoggerForNamespace("buffoonery/auth", lvlDebug)
|
reason: string,
|
||||||
logNs
|
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) =
|
proc failAuth*(reason: string, parentException: ref Exception = nil) =
|
||||||
@ -70,24 +77,28 @@ proc initApiAuthContext*(
|
|||||||
proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
|
proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
|
||||||
## Fetch signing keys for an OAuth issuer. `openIdConfigUrl` is expected to
|
## Fetch signing keys for an OAuth issuer. `openIdConfigUrl` is expected to
|
||||||
## be a well-known URL (ISSUER_BASE/.well-known/openid-configuration)
|
## be a well-known URL (ISSUER_BASE/.well-known/openid-configuration)
|
||||||
|
var jwksKeysURI: string
|
||||||
try:
|
try:
|
||||||
let http = newHttpClient()
|
let http = newHttpClient()
|
||||||
|
|
||||||
# Inspect the OAuth metadata via the well-known address.
|
# Inspect the OAuth metadata via the well-known address.
|
||||||
log().debug "fetchJwks: Fetching metadata from " & openIdConfigUrl
|
|
||||||
let metadata = parseJson(http.getContent(openIdConfigUrl))
|
let metadata = parseJson(http.getContent(openIdConfigUrl))
|
||||||
|
|
||||||
# Fetch the keys from the jwk_keys URI.
|
# Fetch the keys from the jwk_keys URI.
|
||||||
let jwksKeysURI = metadata.getOrFail("jwks_uri").getStr
|
jwksKeysURI = metadata.getOrFail("jwks_uri").getStr
|
||||||
debug "fetchJwks: Fetching JWKs from " & jwksKeysURI
|
|
||||||
let jwksKeys = parseJson(http.getContent(jwksKeysURI))
|
let jwksKeys = parseJson(http.getContent(jwksKeysURI))
|
||||||
|
|
||||||
# Parse and load the keys provided.
|
# Parse and load the keys provided.
|
||||||
return initJwkSet(jwksKeys)
|
return initJwkSet(jwksKeys)
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
log().error "unable to fetch issuer signing keys: " & getCurrentExceptionMsg()
|
#failAuth "unable to fetch isser signing keys"
|
||||||
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 =
|
proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void =
|
||||||
@ -97,7 +108,6 @@ proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void
|
|||||||
if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
|
if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
|
||||||
ctx.issuerKeys[issuer] = keySet
|
ctx.issuerKeys[issuer] = keySet
|
||||||
except:
|
except:
|
||||||
log().error "unable to add a set of signing keys: " & getCurrentExceptionMsg()
|
|
||||||
raise getCurrentException()
|
raise getCurrentException()
|
||||||
|
|
||||||
|
|
||||||
@ -111,8 +121,8 @@ proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gc
|
|||||||
## [OpenID Connect standard discovery mechanism](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
## [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 "JWT is missing 'iss' claim."
|
||||||
if jwt.header.kid.isNone: failAuth "Missing 'kid' header."
|
if jwt.header.kid.isNone: failAuth "JWT is missing 'kid' header."
|
||||||
|
|
||||||
if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
|
if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
|
||||||
|
|
||||||
@ -131,10 +141,13 @@ proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gc
|
|||||||
fetchJWKs(jwtIssuer & "/.well-known/openid-configuration")
|
fetchJWKs(jwtIssuer & "/.well-known/openid-configuration")
|
||||||
return ctx.findSigningKey(jwt, false)
|
return ctx.findSigningKey(jwt, false)
|
||||||
|
|
||||||
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:
|
except:
|
||||||
log().error "unable to find JWT signing key: " & getCurrentExceptionMsg()
|
|
||||||
failAuth("unable to find JWT signing key", getCurrentException())
|
failAuth("unable to find JWT signing key", getCurrentException())
|
||||||
|
|
||||||
|
|
||||||
@ -142,24 +155,30 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
|
|||||||
## Given a JWT, validate that it is a well-formed JWT, validate the issuer's
|
## 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.
|
## signature on the token, and validate all the claims that it preesnts.
|
||||||
try:
|
try:
|
||||||
log().debug "Validating JWT: " & $jwt
|
if jwt.claims.iss.isNone: failAuth "JWT is missing 'iss' claim."
|
||||||
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
|
|
||||||
let jwtIssuer = jwt.claims.iss.get
|
let jwtIssuer = jwt.claims.iss.get
|
||||||
|
|
||||||
if not ctx.trustedIssuers.contains(jwtIssuer):
|
if not ctx.trustedIssuers.contains(jwtIssuer):
|
||||||
failAuth "JWT is issued by $# but we only trust $#" %
|
failAuth(
|
||||||
[jwtIssuer, $ctx.trustedIssuers]
|
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 "Missing 'aud' claim."
|
if jwt.claims.aud.isNone: failAuth "JWT is missing 'aud' claim."
|
||||||
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
|
if jwt.claims.iss.isNone: failAuth "JWT is missing 'iss' claim."
|
||||||
if jwt.claims.sub.isNone: failAuth "Missing 'sub' claim."
|
if jwt.claims.sub.isNone: failAuth "JWT is missing 'sub' claim."
|
||||||
if jwt.claims.exp.isNone: failAuth "Missing or invalid 'exp' claim."
|
if jwt.claims.exp.isNone: failAuth "JWT is missing or invalid 'exp' claim."
|
||||||
|
|
||||||
|
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):
|
if not ctx.validAudiences.contains(jwt.claims.aud.get):
|
||||||
log().debug(
|
failAuth "JWT is not for us (invalid audience)."
|
||||||
"Valid audiences: $#\ttoken audience: $#" %
|
elif jwt.claims["aud"].get.kind == JArray:
|
||||||
[$ctx.validAudiences, jwt.claims.aud.get])
|
# 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)."
|
failAuth "JWT is not for us (invalid audience)."
|
||||||
|
|
||||||
let signingAlgorithm = jwt.header.alg.get
|
let signingAlgorithm = jwt.header.alg.get
|
||||||
@ -176,7 +195,7 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
|
|||||||
failAuth(getCurrentExceptionMsg(), getCurrentException())
|
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
|
## Extracts a valid JWT representing the user's authentication and
|
||||||
## authorization details, if present. If there are no valid credentials an
|
## authorization details, if present. If there are no valid credentials an
|
||||||
## exception is raised.
|
## exception is raised.
|
||||||
@ -200,6 +219,12 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
|||||||
##
|
##
|
||||||
## In the split-cookie mode we also check that the `csrfToken` claim in the
|
## 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.
|
## 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:
|
try:
|
||||||
if req.headers.contains("Authorization"):
|
if req.headers.contains("Authorization"):
|
||||||
@ -223,12 +248,17 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
|||||||
|
|
||||||
# Because this is a web session, check that the CSRF is present and
|
# Because this is a web session, check that the CSRF is present and
|
||||||
# matches.
|
# matches.
|
||||||
|
if validateCsrf:
|
||||||
if not req.headers.contains("X-CSRF-TOKEN") or
|
if not req.headers.contains("X-CSRF-TOKEN") or
|
||||||
not result.claims["csrfToken"].isSome:
|
not result.claims["csrfToken"].isSome:
|
||||||
failAuth "missing CSRF token"
|
failAuth "missing CSRF token"
|
||||||
|
|
||||||
if req.headers["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
|
if req.headers["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
|
||||||
failAuth "invalid CSRF token"
|
failAuth(
|
||||||
|
reason = "invalid CSRF token",
|
||||||
|
additionalInfo = newTable[string, string]({
|
||||||
|
"header": req.headers["X-CSRF-TOKEN"],
|
||||||
|
"jwt": result.claims["csrfToken"].get.getStr("")}))
|
||||||
|
|
||||||
else: failAuth "no auth token, no Authorization or Cookie headers"
|
else: failAuth "no auth token, no Authorization or Cookie headers"
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user