diff --git a/buffoonery.nimble b/buffoonery.nimble index 5a7aebb..a0494bb 100644 --- a/buffoonery.nimble +++ b/buffoonery.nimble @@ -1,6 +1,6 @@ # Package -version = "0.2.1" +version = "0.2.2" author = "Jonathan Bernard" description = "JDB Software's opinionated extensions and auth layer for Jester." license = "MIT" @@ -12,8 +12,8 @@ srcDir = "src" requires "nim >= 1.6.2" requires @["bcrypt", "jester >= 0.5.0", "uuids"] -requires "https://git.jdb-software.com/jdb/nim-jwt-full.git" -requires "https://git.jdb-software.com/jdb/nim-namespaced-logging.git" +requires "https://git.jdb-software.com/jdb/nim-jwt-full.git >= 0.2.0" +requires "https://git.jdb-software.com/jdb/nim-namespaced-logging.git >= 0.3.0" task unittest, "Runs the unit test suite.": exec "nim c -r test/runner" diff --git a/src/buffoonery/apiutils.nim b/src/buffoonery/apiutils.nim index 286e77a..151759e 100644 --- a/src/buffoonery/apiutils.nim +++ b/src/buffoonery/apiutils.nim @@ -8,7 +8,7 @@ const CONTENT_TYPE_JSON* = "application/json" var logNs {.threadvar.}: LoggingNamespace template log(): untyped = - if logNs.isNil: logNs = initLoggingNamespace("buffoonery/apiutils", lvlDebug) + if logNs.isNil: logNs = getLoggerForNamespace("buffoonery/apiutils", lvlDebug) logNs ## Response Utilities diff --git a/src/buffoonery/auth.nim b/src/buffoonery/auth.nim index 13a570a..988a6c7 100644 --- a/src/buffoonery/auth.nim +++ b/src/buffoonery/auth.nim @@ -28,7 +28,7 @@ type var logNs {.threadvar.}: LoggingNamespace template log(): untyped = - if logNs.isNil: logNs = initLoggingNamespace("buffoonery/auth", lvlDebug) + if logNs.isNil: logNs = getLoggerForNamespace("buffoonery/auth", lvlDebug) logNs proc failAuth*(reason: string, parentException: ref Exception = nil) = @@ -91,6 +91,11 @@ proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gcsafe.} = ## Lookup the signing key for a given JWT. This method assumes that you trust ## the issuer named in the JWT. + ## + ## If our ApiAuthContext does not contain any keys for the issuer named in + ## the JWT, or if that set of keys does not contain the key id referenced in + ## the JWT, we will attempt to fetch the signing keys based on the + ## [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." @@ -100,23 +105,20 @@ proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gc let jwtIssuer = jwt.claims.iss.get - # Do we already have keys for this issuer in our cache? if ctx.issuerKeys.hasKey(jwtIssuer): - # Do we have the key for this keyId? let foundKeys = ctx.issuerKeys[jwtIssuer] .filterIt(it.kid.isSome and it.kid.get == jwt.header.kid.get) if foundKeys.len == 1: return foundKeys[0] - # If all of the above were true, we should have returned. If we reach this - # point, we know that one of the above was false and we need to refresh our - # cache of keys. + # If we didn't have keys for that issuer, or if we couldn't find the given + # kid, we need to refresh our cache of keys. if allowFetch: ctx.issuerKeys[jwtIssuer] = fetchJWKs(jwtIssuer & "/.well-known/openid-configuration") return ctx.findSigningKey(jwt, false) - else: failAuth "unable to find JWT signing key" + failAuth "unable to find JWT signing key" except: log.error "unable to find JWT signing key: " & getCurrentExceptionMsg() @@ -165,24 +167,22 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT = ## We support two authentication flows: ## ## - Strict API via a JWT Bearer token in the Authorization header. This is - ## intended for API consumers (not the browser-based web-app). In this - ## case, the token is validated directly. + ## intended for API consumers (not a browser-based web-app). In this case, + ## the token is validated directly. ## ## - Split JWT via two cookies: ## ## - `${cookiePrefix}-user`: Contains the JWT header and payload, but not the - ## signature. This cookie is set Secure. The JWT payload contains a 30 - ## minute expiry (and the Max-Age is set the same) and also contains a - ## CSRF token. This cookie is accessible by the web application. + ## signature. This cookie should be set Secure. The JWT payload should + ## have a defined expiration date (matching the Max-Age of the cookie) + ## and a CSRF token. This cookie is accessible by the web application. + ## ## - `${cookiePrefix}-session`: Contains the JWT signature. This cookie is ## set Secure and HttpOnly. This serves as the session token (when the ## user closes the browser this gets unset). ## - ## In this split-cookie mode, the API will also check for the presence of a - ## CSRF token on any mutation requests (PUT, POST, and DELETE requests). - ## The client must set the X-CSRF-TOKEN header with the same CSRF value - ## present in the `csrfToken` claim in the JWT presented in the - ## `${cookiePrefix}-user` cookie. + ## 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. try: if headers(req).hasKey("Authorization"): @@ -238,6 +238,7 @@ proc createSignedJWT*(ctx: ApiAuthContext, claims: JsonNode, kid: string): JWT = sigKey) proc newApiAccessToken*(ctx: ApiAuthContext, sub: string, duration = 1.hours): JWT = + ## Create a new JWT for API access. result = ctx.createSignedJWT( %*{ "sub": sub,