Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
061b0a44fc | |||
be60254227 | |||
c6d02d7db7 | |||
e44d476d88 | |||
7bc77ac7d7 | |||
e4c7524d3d |
@@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "0.4.4"
|
||||
version = "0.4.10"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Jonathan's opinionated extensions and auth layer for Jester."
|
||||
license = "MIT"
|
||||
|
@@ -1,5 +1,2 @@
|
||||
import buffoonery/apierror,
|
||||
buffoonery/apiutils,
|
||||
buffoonery/auth,
|
||||
buffoonery/jsonutils
|
||||
import buffoonery/[apierror, apiutils, auth, jsonutils]
|
||||
export apierror, apiutils, auth, jsonutils
|
||||
|
@@ -10,6 +10,8 @@ proc newApiError*(parent: ref Exception = nil, respCode: HttpCode, respMsg: stri
|
||||
result = newException(ApiError, msg, parent)
|
||||
result.respCode = respCode
|
||||
result.respMsg = respMsg
|
||||
|
||||
if not parent.isNil:
|
||||
result.trace &= parent.trace
|
||||
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import std/[json, jsonutils, options, sequtils, strtabs, strutils]
|
||||
import mummy, webby
|
||||
import mummy, webby, uuids
|
||||
|
||||
import std/httpcore except HttpHeaders
|
||||
|
||||
@@ -31,7 +31,7 @@ func initApiResponse*[T](
|
||||
totalItems: totalItems, nextLink: nextLink, prevLink: prevLink)
|
||||
|
||||
|
||||
func `%`*(r: ApiResponse): JsonNode =
|
||||
proc `%`*(r: ApiResponse): JsonNode =
|
||||
result = newJObject()
|
||||
if r.details.isSome: result["details"] = %r.details
|
||||
if r.data.isSome: result["data"] = %r.data
|
||||
@@ -47,6 +47,7 @@ func `$`*(r: ApiResponse): string = $(%r)
|
||||
proc makeCorsHeaders*(
|
||||
allowedMethods: seq[string],
|
||||
allowedOrigins: seq[string],
|
||||
allowedHeaders: Option[seq[string]],
|
||||
reqOrigin = none[string]()): HttpHeaders =
|
||||
|
||||
result =
|
||||
@@ -55,7 +56,12 @@ proc makeCorsHeaders*(
|
||||
"Access-Control-Allow-Origin": reqOrigin.get,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"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:
|
||||
if reqOrigin.isSome:
|
||||
@@ -67,8 +73,16 @@ proc makeCorsHeaders*(
|
||||
proc makeCorsHeaders*(
|
||||
allowedMethods: seq[HttpMethod],
|
||||
allowedOrigins: seq[string],
|
||||
allowedHeaders: Option[seq[string]],
|
||||
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] =
|
||||
@@ -76,6 +90,40 @@ func origin*(req: Request): Option[string] =
|
||||
else: none[string]()
|
||||
|
||||
|
||||
func traceparent*(req: Request): Option[string] =
|
||||
## Extract the traceparent from the request headers, if present.
|
||||
if req.headers.contains("traceparent"):
|
||||
return some(req.headers["traceparent"])
|
||||
else:
|
||||
return none[string]()
|
||||
|
||||
|
||||
proc makeTraceContextHeaders*(req: Request, traceParentId: string): HttpHeaders =
|
||||
var headers = HttpHeaders(@[])
|
||||
|
||||
if req.headers.contains("traceparent"):
|
||||
# If the traceparent header is present, we should update it with our
|
||||
# parent-id.
|
||||
let traceparentParts = req.headers["traceparent"].split("-")
|
||||
if traceparentParts.len != 4:
|
||||
headers["traceparent"] = "00-$#-$#-00" % [
|
||||
replace($genUUID(), "-", ""), # trace-id
|
||||
traceParentId, # parent-id
|
||||
]
|
||||
|
||||
else:
|
||||
headers["traceparent"] = "00-$#-$#-$#" % [
|
||||
traceparentParts[1], # trace-id
|
||||
traceParentId, # parent-id
|
||||
traceparentParts[3], # flags
|
||||
]
|
||||
|
||||
if req.headers.contains("tracestate"):
|
||||
headers["tracestate"] = req.headers["tracestate"]
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
proc respondWithRawJson*(
|
||||
req: Request,
|
||||
body: JsonNode,
|
||||
|
@@ -4,13 +4,14 @@ import std/httpclient except HttpHeaders
|
||||
|
||||
import jwt_full, jwt_full/encoding
|
||||
|
||||
import ./apiutils, ./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
|
||||
@@ -28,6 +29,19 @@ type
|
||||
issuerKeys: TableRef[string, JwkSet]
|
||||
|
||||
|
||||
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.
|
||||
@@ -63,6 +77,7 @@ proc initApiAuthContext*(
|
||||
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()
|
||||
|
||||
@@ -70,14 +85,20 @@ proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
|
||||
let metadata = parseJson(http.getContent(openIdConfigUrl))
|
||||
|
||||
# Fetch the keys from the jwk_keys URI.
|
||||
let jwksKeysURI = metadata.getOrFail("jwks_uri").getStr
|
||||
jwksKeysURI = metadata.getOrFail("jwks_uri").getStr
|
||||
let jwksKeys = parseJson(http.getContent(jwksKeysURI))
|
||||
|
||||
# Parse and load the keys provided.
|
||||
return initJwkSet(jwksKeys)
|
||||
|
||||
except:
|
||||
failAuth("unable to fetch isser signing keys", getCurrentException())
|
||||
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 =
|
||||
@@ -100,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)
|
||||
|
||||
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]()
|
||||
|
||||
@@ -120,7 +141,11 @@ proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gc
|
||||
fetchJWKs(jwtIssuer & "/.well-known/openid-configuration")
|
||||
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:
|
||||
failAuth("unable to find JWT signing key", getCurrentException())
|
||||
@@ -130,18 +155,21 @@ 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:
|
||||
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.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 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"].get.kind == JString:
|
||||
# If the token is for a single audience, check that it is for us.
|
||||
@@ -167,7 +195,7 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
|
||||
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.
|
||||
@@ -191,6 +219,12 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
||||
##
|
||||
## 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 req.headers.contains("Authorization"):
|
||||
@@ -214,12 +248,17 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
||||
|
||||
# Because this is a web session, check that the CSRF is present and
|
||||
# matches.
|
||||
if validateCsrf:
|
||||
if not req.headers.contains("X-CSRF-TOKEN") or
|
||||
not result.claims["csrfToken"].isSome:
|
||||
failAuth "missing CSRF token"
|
||||
|
||||
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"
|
||||
|
||||
|
Reference in New Issue
Block a user