Compare commits
1 Commits
0.4.1
...
0.3.1-aud-
| Author | SHA1 | Date | |
|---|---|---|---|
| ac2edf230d |
@@ -1,6 +1,6 @@
|
|||||||
# Package
|
# Package
|
||||||
|
|
||||||
version = "0.4.1"
|
version = "0.3.1"
|
||||||
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"
|
||||||
@@ -12,7 +12,7 @@ srcDir = "src"
|
|||||||
requires "nim >= 1.6.2"
|
requires "nim >= 1.6.2"
|
||||||
|
|
||||||
# from standard nimble repo
|
# from standard nimble repo
|
||||||
requires @["bcrypt", "mummy", "uuids", "webby"]
|
requires @["bcrypt", "jester >= 0.5.0", "uuids"]
|
||||||
|
|
||||||
# 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", "namespaced_logging >= 0.3.0"]
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
from strutils import isEmptyOrWhitespace
|
from strutils import isEmptyOrWhitespace
|
||||||
from httpcore import HttpCode
|
from httpclient import HttpCode
|
||||||
|
|
||||||
type ApiError* = object of CatchableError
|
type ApiError* = object of CatchableError
|
||||||
respMsg*: string
|
respMsg*: string
|
||||||
respCode*: HttpCode
|
respCode*: HttpCode
|
||||||
|
|
||||||
|
|
||||||
proc newApiError*(parent: ref Exception = nil, respCode: HttpCode, respMsg: string, msg = ""): ref ApiError =
|
proc newApiError*(parent: ref Exception = nil, respCode: HttpCode, respMsg: string, msg = ""): ref ApiError =
|
||||||
result = newException(ApiError, msg, parent)
|
result = newException(ApiError, msg, parent)
|
||||||
result.respCode = respCode
|
result.respCode = respCode
|
||||||
result.respMsg = respMsg
|
result.respMsg = respMsg
|
||||||
|
|
||||||
|
|
||||||
proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
||||||
var apiError = newApiError(
|
var apiError = newApiError(
|
||||||
parent = nil,
|
parent = nil,
|
||||||
@@ -21,13 +19,10 @@ proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
|||||||
else: msg)
|
else: 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,
|
||||||
respMsg =
|
respMsg = respMsg,
|
||||||
if respMsg.isEmptyOrWhitespace: parent.msg
|
|
||||||
else: respMsg,
|
|
||||||
msg = msg)
|
msg = msg)
|
||||||
raise apiError
|
raise apiError
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import std/[json, jsonutils, logging, options, sequtils, strtabs, strutils]
|
import std/[json, jsonutils, logging, options, strutils, sequtils]
|
||||||
import mummy, namespaced_logging, webby
|
import jester, namespaced_logging
|
||||||
|
|
||||||
import std/httpcore except HttpHeaders
|
|
||||||
|
|
||||||
import ./apierror
|
import ./apierror
|
||||||
|
|
||||||
@@ -16,15 +14,13 @@ template log(): untyped =
|
|||||||
## Response Utilities
|
## Response Utilities
|
||||||
## ------------------
|
## ------------------
|
||||||
|
|
||||||
type
|
type ApiResponse*[T] = object
|
||||||
ApiResponse*[T] = object
|
details*: Option[string]
|
||||||
details*: Option[string]
|
data*: Option[T]
|
||||||
data*: Option[T]
|
nextOffset*: Option[int]
|
||||||
nextOffset*: Option[int]
|
totalItems*: Option[int]
|
||||||
totalItems*: Option[int]
|
nextLink*: Option[string]
|
||||||
nextLink*: Option[string]
|
prevLink*: Option[string]
|
||||||
prevLink*: Option[string]
|
|
||||||
|
|
||||||
|
|
||||||
func initApiResponse*[T](
|
func initApiResponse*[T](
|
||||||
details = none[string](),
|
details = none[string](),
|
||||||
@@ -36,7 +32,6 @@ func initApiResponse*[T](
|
|||||||
ApiResponse[T](details: details, data: data, nextOffset: nextOffset,
|
ApiResponse[T](details: details, data: data, nextOffset: nextOffset,
|
||||||
totalItems: totalItems, nextLink: nextLink, prevLink: prevLink)
|
totalItems: totalItems, nextLink: nextLink, prevLink: prevLink)
|
||||||
|
|
||||||
|
|
||||||
func `%`*(r: ApiResponse): JsonNode =
|
func `%`*(r: ApiResponse): JsonNode =
|
||||||
result = newJObject()
|
result = newJObject()
|
||||||
if r.details.isSome: result["details"] = %r.details
|
if r.details.isSome: result["details"] = %r.details
|
||||||
@@ -46,86 +41,89 @@ func `%`*(r: ApiResponse): JsonNode =
|
|||||||
if r.nextLink.isSome: result["nextLink"] = %r.nextLink
|
if r.nextLink.isSome: result["nextLink"] = %r.nextLink
|
||||||
if r.prevLink.isSome: result["prevLink"] = %r.prevLink
|
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
|
||||||
|
|
||||||
func `$`*(r: ApiResponse): string = $(%r)
|
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: ""
|
||||||
|
|
||||||
|
let corsHeaders =
|
||||||
proc makeCorsHeaders*(
|
if knownOrigins.contains(reqOrigin):
|
||||||
allowedMethods: seq[string],
|
|
||||||
allowedOrigins: seq[string],
|
|
||||||
reqOrigin = none[string]()): HttpHeaders =
|
|
||||||
|
|
||||||
result =
|
|
||||||
if reqOrigin.isSome and allowedOrigins.contains(reqOrigin.get):
|
|
||||||
@{
|
@{
|
||||||
"Access-Control-Allow-Origin": reqOrigin.get,
|
"Access-Control-Allow-Origin": reqOrigin,
|
||||||
"Access-Control-Allow-Credentials": "true",
|
"Access-Control-Allow-Credentials": "true",
|
||||||
"Access-Control-Allow-Methods": allowedMethods.join(", "),
|
"Access-Control-Allow-Methods": $(reqMethod(request)),
|
||||||
"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": "Authorization,X-CSRF-TOKEN"
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
if reqOrigin.isSome:
|
log().debug "Unrecognized Origin '" & reqOrigin & "', excluding CORS headers."
|
||||||
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(
|
||||||
|
code,
|
||||||
|
cast[RawHeaders](headersToSend) & corsHeaders & @{
|
||||||
|
"Content-Type": CONTENT_TYPE_JSON,
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
},
|
||||||
|
$body
|
||||||
|
)
|
||||||
|
|
||||||
proc makeCorsHeaders*(
|
template sendResp*[T](
|
||||||
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],
|
resp: ApiResponse[T],
|
||||||
code = Http200,
|
code = Http200,
|
||||||
allowedOrigins = newSeq[string](),
|
allowedOrigins = newSeq[string](),
|
||||||
headersToSend: HttpHeaders = @{:}) =
|
headersToSend: RawHeaders = @{:}) =
|
||||||
req.respondWithRawJson(%resp, code, allowedOrigins, headersToSend)
|
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)
|
||||||
|
|
||||||
proc respondWithData*[T](
|
## CORS support
|
||||||
req: Request,
|
template sendOptionsResp*(
|
||||||
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],
|
allowedMethods: seq[HttpMethod],
|
||||||
allowedOrigins: seq[string]) =
|
knownOrigins: seq[string]) =
|
||||||
|
|
||||||
req.respond(
|
let reqOrigin =
|
||||||
Http200.ord,
|
if headers(request).hasKey("Origin"): $(headers(request)["Origin"])
|
||||||
makeCorsHeaders(allowedMethods, allowedOrigins, req.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-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(", ")
|
||||||
|
@{:}
|
||||||
|
|
||||||
|
halt(
|
||||||
|
Http200,
|
||||||
|
corsHeaders,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import std/[cookies, json, logging, options, sequtils, strtabs,
|
import std/httpclient, std/json, std/logging, std/options, std/sequtils,
|
||||||
strutils, tables, times]
|
std/strutils, std/tables, std/times
|
||||||
import mummy, namespaced_logging, uuids, webby
|
import jester, namespaced_logging
|
||||||
import std/httpclient except HttpHeaders
|
|
||||||
|
|
||||||
import jwt_full, jwt_full/encoding
|
import jwt_full, jwt_full/encoding
|
||||||
|
|
||||||
import ./apiutils, ./jsonutils
|
import ./jsonutils
|
||||||
|
|
||||||
const SUPPORTED_SIGNATURE_ALGORITHMS = @[ HS256, RS256 ]
|
const SUPPORTED_SIGNATURE_ALGORITHMS = @[ HS256, RS256 ]
|
||||||
|
|
||||||
|
|
||||||
type
|
type
|
||||||
AuthError* = object of CatchableError
|
AuthError* = object of CatchableError
|
||||||
|
|
||||||
ApiAuthContext* = ref object
|
ApiAuthContext* = ref object
|
||||||
appDomain*: string ## Application domain for session cookies
|
|
||||||
cookiePrefix*: string ## Prefix for the user and session cookies
|
cookiePrefix*: string ## Prefix for the user and session cookies
|
||||||
validAudiences*: seq[string] ## Expected audience values for for `aud` JWT check
|
validAudiences*: seq[string] ## Expected audience values for for `aud` JWT check
|
||||||
issuer*: string ## The JWT issuer for tokens created by this API
|
issuer*: string ## The JWT issuer for tokens created by this API
|
||||||
@@ -34,20 +31,14 @@ template log(): untyped =
|
|||||||
if logNs.isNil: logNs = getLoggerForNamespace("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) =
|
||||||
## Syntactic sugar to raise an AuthError. Reason will be the exception
|
|
||||||
## message and should be considered an internal message.
|
|
||||||
raise newException(AuthError, reason, parentException)
|
raise newException(AuthError, reason, parentException)
|
||||||
|
|
||||||
|
|
||||||
proc validateSigningKey(k: JWK): void =
|
proc validateSigningKey(k: JWK): void =
|
||||||
if k.alg.isNone: failAuth "JWK is missing 'alg'"
|
if k.alg.isNone: failAuth "JWK is missing 'alg'"
|
||||||
if k.kid.isNone: failAuth "JWK is missing 'kid'"
|
if k.kid.isNone: failAuth "JWK is missing 'kid'"
|
||||||
|
|
||||||
|
|
||||||
proc initApiAuthContext*(
|
proc initApiAuthContext*(
|
||||||
appDomain: string,
|
|
||||||
cookiePrefix: string,
|
cookiePrefix: string,
|
||||||
validAudiences: seq[string],
|
validAudiences: seq[string],
|
||||||
issuer: string,
|
issuer: string,
|
||||||
@@ -58,7 +49,6 @@ proc initApiAuthContext*(
|
|||||||
for k in signingKeys: validateSigningKey(k)
|
for k in signingKeys: validateSigningKey(k)
|
||||||
|
|
||||||
result = ApiAuthContext(
|
result = ApiAuthContext(
|
||||||
appDomain: appDomain,
|
|
||||||
cookiePrefix: cookiePrefix,
|
cookiePrefix: cookiePrefix,
|
||||||
validAudiences: validAudiences,
|
validAudiences: validAudiences,
|
||||||
issuer: issuer,
|
issuer: issuer,
|
||||||
@@ -66,7 +56,6 @@ proc initApiAuthContext*(
|
|||||||
signingKid: signingKid,
|
signingKid: signingKid,
|
||||||
issuerKeys: newTable[string, JwkSet]([(issuer, signingKeys)]))
|
issuerKeys: newTable[string, JwkSet]([(issuer, signingKeys)]))
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -74,7 +63,7 @@ proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
|
|||||||
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
|
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.
|
||||||
@@ -86,10 +75,9 @@ proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
|
|||||||
return initJwkSet(jwksKeys)
|
return initJwkSet(jwksKeys)
|
||||||
|
|
||||||
except:
|
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"
|
failAuth "unable to fetch isser signing keys"
|
||||||
|
|
||||||
|
|
||||||
proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void =
|
proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void =
|
||||||
## Manually add a set of signing keys associated with a given issuer.
|
## Manually add a set of signing keys associated with a given issuer.
|
||||||
try:
|
try:
|
||||||
@@ -97,10 +85,9 @@ 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()
|
log.error "unable to add a set of signing keys: " & getCurrentExceptionMsg()
|
||||||
raise getCurrentException()
|
raise getCurrentException()
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -134,15 +121,14 @@ proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gc
|
|||||||
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()
|
||||||
failAuth("unable to find JWT signing key", getCurrentException())
|
failAuth("unable to find JWT signing key", getCurrentException())
|
||||||
|
|
||||||
|
|
||||||
proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
|
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
|
log.debug "Validating JWT: " & $jwt
|
||||||
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
|
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
|
||||||
let jwtIssuer = jwt.claims.iss.get
|
let jwtIssuer = jwt.claims.iss.get
|
||||||
|
|
||||||
@@ -151,16 +137,18 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
|
|||||||
[jwtIssuer, $ctx.trustedIssuers]
|
[jwtIssuer, $ctx.trustedIssuers]
|
||||||
|
|
||||||
if jwt.header.alg.isNone: failAuth "Missing 'alg' header property."
|
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):
|
if jwt.claims.aud.isNone: failAuth "Missing 'aud' claim."
|
||||||
log().debug(
|
|
||||||
"Valid audiences: $#\ttoken audience: $#" %
|
if jwt.claims["aud"].get.kind == JString:
|
||||||
[$ctx.validAudiences, jwt.claims.aud.get])
|
# If the token is for a single audience, check that it is for us.
|
||||||
failAuth "JWT is not for us (invalid audience)."
|
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
|
let signingAlgorithm = jwt.header.alg.get
|
||||||
|
|
||||||
@@ -175,7 +163,6 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
|
|||||||
except:
|
except:
|
||||||
failAuth(getCurrentExceptionMsg(), getCurrentException())
|
failAuth(getCurrentExceptionMsg(), getCurrentException())
|
||||||
|
|
||||||
|
|
||||||
proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
proc extractValidJwt*(ctx: ApiAuthContext, req: Request): 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
|
||||||
@@ -190,7 +177,7 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
|||||||
## - 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 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)
|
## have a defined expiration date (matching the Max-Age of the cookie)
|
||||||
## and a CSRF token. This cookie is accessible by the web application.
|
## and a CSRF token. This cookie is accessible by the web application.
|
||||||
##
|
##
|
||||||
@@ -202,73 +189,37 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
|||||||
## 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.
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if req.headers.contains("Authorization"):
|
if headers(req).hasKey("Authorization"):
|
||||||
# Using a Bearer token.
|
# Using a Bearer token.
|
||||||
result = toJWT(req.headers["Authorization"][7..^1])
|
result = toJWT(headers(req)["Authorization"][7..^1])
|
||||||
|
|
||||||
elif req.headers.contains("Cookie"):
|
else:
|
||||||
# Using a user/session cookie pair
|
# Using a user/session cookie pair
|
||||||
let userCookieName = ctx.cookiePrefix & "-user"
|
let userCookieName = ctx.cookiePrefix & "-user"
|
||||||
let sessionCookieName = ctx.cookiePrefix & "-session"
|
let sessionCookieName = ctx.cookiePrefix & "-session"
|
||||||
|
|
||||||
let cookies = parseCookies(req.headers["Cookie"])
|
if not cookies(req).hasKey(userCookieName):
|
||||||
if not cookies.contains(userCookieName):
|
|
||||||
failAuth "missing cookie '$#'" % userCookieName
|
failAuth "missing cookie '$#'" % userCookieName
|
||||||
if not cookies.contains(sessionCookieName):
|
if not cookies(req).hasKey(sessionCookieName):
|
||||||
failAuth "missing cookie '$#'" % sessionCookieName
|
failAuth "missing cookie '$#'" % sessionCookieName
|
||||||
|
|
||||||
let userVal = cookies[userCookieName]
|
let userVal = cookies(req)[userCookieName]
|
||||||
let sessionVal = cookies[sessionCookieName]
|
let sessionVal = cookies(req)[sessionCookieName]
|
||||||
result = toJWT(userVal & "." & sessionVal)
|
result = toJWT(userVal & "." & sessionVal)
|
||||||
|
|
||||||
# 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 not req.headers.contains("X-CSRF-TOKEN") or
|
if not headers(req).hasKey("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 headers(req)["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
|
||||||
failAuth "invalid CSRF token"
|
failAuth "invalid CSRF token"
|
||||||
|
|
||||||
else: failAuth "no auth token, no Authorization or Cookie headers"
|
ctx.validateJwt(result)
|
||||||
|
|
||||||
ctx.validateJWT(result)
|
|
||||||
except:
|
except:
|
||||||
failAuth(getCurrentExceptionMsg(), getCurrentException())
|
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 =
|
proc createSignedJWT*(ctx: ApiAuthContext, claims: JsonNode, kid: string): JWT =
|
||||||
## Given a set of claims, create a JWT using the given key for our issuer
|
## 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
|
## (as defined in the ApiAuthContext). This is an opinionated method that
|
||||||
@@ -290,12 +241,10 @@ proc createSignedJWT*(ctx: ApiAuthContext, claims: JsonNode, kid: string): JWT =
|
|||||||
initJwtClaims(claims),
|
initJwtClaims(claims),
|
||||||
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.
|
## Create a new JWT for API access.
|
||||||
result = ctx.createSignedJWT(
|
result = ctx.createSignedJWT(
|
||||||
%*{
|
%*{
|
||||||
"sid": $genUUID(),
|
|
||||||
"sub": sub,
|
"sub": sub,
|
||||||
"iss": ctx.issuer,
|
"iss": ctx.issuer,
|
||||||
"iat": now().utc.toTime.toUnix.int,
|
"iat": now().utc.toTime.toUnix.int,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import json, times, timeutils, uuids
|
|||||||
|
|
||||||
const MONTH_FORMAT* = "YYYY-MM"
|
const MONTH_FORMAT* = "YYYY-MM"
|
||||||
|
|
||||||
|
|
||||||
func 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
|
## convenience method to get a key from a JObject or raise an exception
|
||||||
if not n.hasKey(key):
|
if not n.hasKey(key):
|
||||||
@@ -11,18 +10,14 @@ func getOrFail*(n: JsonNode, key: string): JsonNode =
|
|||||||
|
|
||||||
return n[key]
|
return n[key]
|
||||||
|
|
||||||
|
|
||||||
func parseUUID*(n: JsonNode, key: string): UUID =
|
func parseUUID*(n: JsonNode, key: string): UUID =
|
||||||
return parseUUID(n.getOrFail(key).getStr)
|
return parseUUID(n.getOrFail(key).getStr)
|
||||||
|
|
||||||
|
|
||||||
proc parseIso8601*(n: JsonNode, key: string): DateTime =
|
proc parseIso8601*(n: JsonNode, key: string): DateTime =
|
||||||
return parseIso8601(n.getOrFail(key).getStr)
|
return parseIso8601(n.getOrFail(key).getStr)
|
||||||
|
|
||||||
|
|
||||||
proc parseMonth*(n: JsonNode, key: string): DateTime =
|
proc parseMonth*(n: JsonNode, key: string): DateTime =
|
||||||
return parse(n.getOrFail(key).getStr, MONTH_FORMAT)
|
return parse(n.getOrFail(key).getStr, MONTH_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
func formatMonth*(dt: DateTime): string =
|
func formatMonth*(dt: DateTime): string =
|
||||||
return dt.format(MONTH_FORMAT)
|
return dt.format(MONTH_FORMAT)
|
||||||
|
|||||||
Reference in New Issue
Block a user