2 Commits
0.4.6 ... 0.4.8

Author SHA1 Message Date
c6d02d7db7 Update default allowed headers in makeCorsHeaders
- Allow the caller to optionally provide a list
- Add `traceparent`, `tracestate`, `X-Request-ID` and `X-Correlation-Id`
  by default.
2025-04-18 09:40:16 -05:00
e44d476d88 Add additional information to AuthErrors raised. 2025-02-15 07:21:32 -06:00
4 changed files with 67 additions and 24 deletions

View File

@ -1,6 +1,6 @@
# Package
version = "0.4.6"
version = "0.4.8"
author = "Jonathan Bernard"
description = "Jonathan's opinionated extensions and auth layer for Jester."
license = "MIT"

View File

@ -1,5 +1,2 @@
import buffoonery/apierror,
buffoonery/apiutils,
buffoonery/auth,
buffoonery/jsonutils
import buffoonery/[apierror, apiutils, auth, jsonutils]
export apierror, apiutils, auth, jsonutils

View File

@ -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] =

View File

@ -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.
@ -226,7 +254,11 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request, validateCsrf = true): J
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"