diff --git a/buffoonery.nimble b/buffoonery.nimble index 4cff2f0..8993793 100644 --- a/buffoonery.nimble +++ b/buffoonery.nimble @@ -1,6 +1,6 @@ # Package -version = "0.4.6" +version = "0.4.7" author = "Jonathan Bernard" description = "Jonathan's opinionated extensions and auth layer for Jester." license = "MIT" diff --git a/src/buffoonery.nim b/src/buffoonery.nim index a1aec7d..adab7a4 100644 --- a/src/buffoonery.nim +++ b/src/buffoonery.nim @@ -1,5 +1,2 @@ -import buffoonery/apierror, - buffoonery/apiutils, - buffoonery/auth, - buffoonery/jsonutils +import buffoonery/[apierror, apiutils, auth, jsonutils] export apierror, apiutils, auth, jsonutils diff --git a/src/buffoonery/auth.nim b/src/buffoonery/auth.nim index bb46384..4e7e27e 100644 --- a/src/buffoonery/auth.nim +++ b/src/buffoonery/auth.nim @@ -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"