|
|
|
@ -1,6 +1,5 @@
|
|
|
|
|
import std/[cookies, json, logging, options, sequtils, strtabs,
|
|
|
|
|
strutils, tables, times]
|
|
|
|
|
import mummy, namespaced_logging, uuids, webby
|
|
|
|
|
import std/[cookies, json, options, sequtils, strtabs, strutils, tables, times]
|
|
|
|
|
import mummy, uuids, webby
|
|
|
|
|
import std/httpclient except HttpHeaders
|
|
|
|
|
|
|
|
|
|
import jwt_full, jwt_full/encoding
|
|
|
|
@ -28,12 +27,6 @@ type
|
|
|
|
|
|
|
|
|
|
issuerKeys: TableRef[string, JwkSet]
|
|
|
|
|
|
|
|
|
|
var logNs {.threadvar.}: LoggingNamespace
|
|
|
|
|
|
|
|
|
|
template log(): untyped =
|
|
|
|
|
if logNs.isNil: logNs = getLoggerForNamespace("buffoonery/auth", lvlDebug)
|
|
|
|
|
logNs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc failAuth*(reason: string, parentException: ref Exception = nil) =
|
|
|
|
|
## Syntactic sugar to raise an AuthError. Reason will be the exception
|
|
|
|
@ -74,20 +67,17 @@ proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
|
|
|
|
|
let http = newHttpClient()
|
|
|
|
|
|
|
|
|
|
# Inspect the OAuth metadata via the well-known address.
|
|
|
|
|
log().debug "fetchJwks: Fetching metadata from " & openIdConfigUrl
|
|
|
|
|
let metadata = parseJson(http.getContent(openIdConfigUrl))
|
|
|
|
|
|
|
|
|
|
# Fetch the keys from the jwk_keys URI.
|
|
|
|
|
let jwksKeysURI = metadata.getOrFail("jwks_uri").getStr
|
|
|
|
|
debug "fetchJwks: Fetching JWKs from " & jwksKeysURI
|
|
|
|
|
let jwksKeys = parseJson(http.getContent(jwksKeysURI))
|
|
|
|
|
|
|
|
|
|
# Parse and load the keys provided.
|
|
|
|
|
return initJwkSet(jwksKeys)
|
|
|
|
|
|
|
|
|
|
except:
|
|
|
|
|
log().error "unable to fetch issuer signing keys: " & getCurrentExceptionMsg()
|
|
|
|
|
failAuth "unable to fetch isser signing keys"
|
|
|
|
|
failAuth("unable to fetch isser signing keys", getCurrentException())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void =
|
|
|
|
@ -97,7 +87,6 @@ proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void
|
|
|
|
|
if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
|
|
|
|
|
ctx.issuerKeys[issuer] = keySet
|
|
|
|
|
except:
|
|
|
|
|
log().error "unable to add a set of signing keys: " & getCurrentExceptionMsg()
|
|
|
|
|
raise getCurrentException()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -134,7 +123,6 @@ proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gc
|
|
|
|
|
failAuth "unable to find JWT signing key"
|
|
|
|
|
|
|
|
|
|
except:
|
|
|
|
|
log().error "unable to find JWT signing key: " & getCurrentExceptionMsg()
|
|
|
|
|
failAuth("unable to find JWT signing key", getCurrentException())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -142,7 +130,6 @@ 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:
|
|
|
|
|
log().debug "Validating JWT: " & $jwt
|
|
|
|
|
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
|
|
|
|
|
let jwtIssuer = jwt.claims.iss.get
|
|
|
|
|
|
|
|
|
@ -156,11 +143,15 @@ proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
|
|
|
|
|
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):
|
|
|
|
|
log().debug(
|
|
|
|
|
"Valid audiences: $#\ttoken audience: $#" %
|
|
|
|
|
[$ctx.validAudiences, jwt.claims.aud.get])
|
|
|
|
|
failAuth "JWT is not for us (invalid audience)."
|
|
|
|
|
if jwt.claims["aud"].get.kind == JString:
|
|
|
|
|
# If the token is for a single audience, check that it is for us.
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
@ -176,7 +167,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.
|
|
|
|
@ -200,6 +191,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"):
|
|
|
|
@ -223,12 +220,13 @@ proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
|
|
|
|
|
|
|
|
|
|
# Because this is a web session, check that the CSRF is present and
|
|
|
|
|
# matches.
|
|
|
|
|
if not req.headers.contains("X-CSRF-TOKEN") or
|
|
|
|
|
not result.claims["csrfToken"].isSome:
|
|
|
|
|
failAuth "missing CSRF token"
|
|
|
|
|
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"
|
|
|
|
|
if req.headers["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
|
|
|
|
|
failAuth "invalid CSRF token"
|
|
|
|
|
|
|
|
|
|
else: failAuth "no auth token, no Authorization or Cookie headers"
|
|
|
|
|
|
|
|
|
|