|
|
|
@@ -0,0 +1,245 @@
|
|
|
|
|
import std/httpclient, std/json, std/logging, std/options, std/sequtils,
|
|
|
|
|
std/strutils, std/tables, std/times
|
|
|
|
|
import jester, namespaced_logging
|
|
|
|
|
|
|
|
|
|
import jwt_full, jwt_full/encoding
|
|
|
|
|
|
|
|
|
|
import ./jsonutils
|
|
|
|
|
|
|
|
|
|
const SUPPORTED_SIGNATURE_ALGORITHMS = @[ HS256, RS256 ]
|
|
|
|
|
|
|
|
|
|
type
|
|
|
|
|
AuthError* = object of CatchableError
|
|
|
|
|
|
|
|
|
|
ApiAuthContext* = ref object
|
|
|
|
|
cookiePrefix*: string ## Prefix for the user and session cookies
|
|
|
|
|
validAudiences*: seq[string] ## Expected audience values for for `aud` JWT check
|
|
|
|
|
issuer*: string ## The JWT issuer for tokens created by this API
|
|
|
|
|
trustedIssuers*: seq[string] ## The list of trusted OAuth 2.0 issuers
|
|
|
|
|
|
|
|
|
|
signingKid*: string
|
|
|
|
|
## The key id to use for signing JWT tokens created by this API. The JWK
|
|
|
|
|
## representing this signing key with the private or secret key value
|
|
|
|
|
## must be provided either when the ApiAuthContext is initialized (see
|
|
|
|
|
## `initApiAuthContext` or via `addSigningKeys`
|
|
|
|
|
|
|
|
|
|
issuerKeys: TableRef[string, JwkSet]
|
|
|
|
|
|
|
|
|
|
var logNs {.threadvar.}: LoggingNamespace
|
|
|
|
|
|
|
|
|
|
template log(): untyped =
|
|
|
|
|
if logNs.isNil: logNs = initLoggingNamespace("buffoonery/auth", lvlDebug)
|
|
|
|
|
logNs
|
|
|
|
|
|
|
|
|
|
proc failAuth*(reason: string, parentException: ref Exception = nil) =
|
|
|
|
|
raise newException(AuthError, reason, parentException)
|
|
|
|
|
|
|
|
|
|
proc validateSigningKey(k: JWK): void =
|
|
|
|
|
if k.alg.isNone: failAuth "JWK is missing 'alg'"
|
|
|
|
|
if k.kid.isNone: failAuth "JWK is missing 'kid'"
|
|
|
|
|
|
|
|
|
|
proc initApiAuthContext*(
|
|
|
|
|
cookiePrefix: string,
|
|
|
|
|
validAudiences: seq[string],
|
|
|
|
|
issuer: string,
|
|
|
|
|
trustedIssuers: seq[string],
|
|
|
|
|
signingKid: string,
|
|
|
|
|
signingKeys: JwkSet): ApiAuthContext =
|
|
|
|
|
|
|
|
|
|
for k in signingKeys: validateSigningKey(k)
|
|
|
|
|
|
|
|
|
|
result = ApiAuthContext(
|
|
|
|
|
cookiePrefix: cookiePrefix,
|
|
|
|
|
validAudiences: validAudiences,
|
|
|
|
|
issuer: issuer,
|
|
|
|
|
trustedIssuers: trustedIssuers,
|
|
|
|
|
signingKid: signingKid,
|
|
|
|
|
issuerKeys: newTable[string, JwkSet]([(issuer, signingKeys)]))
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
try:
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void =
|
|
|
|
|
## Manually add a set of signing keys associated with a given issuer.
|
|
|
|
|
try:
|
|
|
|
|
for k in keySet: validateSigningKey(k)
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
|
|
|
|
|
if jwt.header.kid.isNone: failAuth "Missing 'kid' header."
|
|
|
|
|
|
|
|
|
|
if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
|
|
|
|
|
|
|
|
|
|
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 allowFetch:
|
|
|
|
|
ctx.issuerKeys[jwtIssuer] =
|
|
|
|
|
fetchJWKs(jwtIssuer & "/.well-known/openid-configuration")
|
|
|
|
|
return ctx.findSigningKey(jwt, false)
|
|
|
|
|
|
|
|
|
|
else: 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())
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
if not ctx.trustedIssuers.contains(jwtIssuer):
|
|
|
|
|
failAuth "JWT is issued by $# but we only trust $#" %
|
|
|
|
|
[jwtIssuer, $ctx.trustedIssuers]
|
|
|
|
|
|
|
|
|
|
if jwt.header.alg.isNone: failAuth "Missing 'alg' header property."
|
|
|
|
|
|
|
|
|
|
if jwt.claims.aud.isNone or
|
|
|
|
|
not ctx.validAudiences.contains(jwt.claims.aud.get):
|
|
|
|
|
failAuth "JWT is not for us (invalid audience)."
|
|
|
|
|
failAuth "Issuer is trusted, but the token is not for the expected audience."
|
|
|
|
|
|
|
|
|
|
let signingAlgorithm = jwt.header.alg.get
|
|
|
|
|
|
|
|
|
|
if not SUPPORTED_SIGNATURE_ALGORITHMS.contains(signingAlgorithm):
|
|
|
|
|
failAuth "unacceptable signature algorithm: " & $signingAlgorithm
|
|
|
|
|
|
|
|
|
|
jwt.validate(
|
|
|
|
|
sigAlg = signingAlgorithm,
|
|
|
|
|
key = ctx.findSigningKey(jwt),
|
|
|
|
|
validateTimeClaims = true)
|
|
|
|
|
|
|
|
|
|
except:
|
|
|
|
|
failAuth(getCurrentExceptionMsg(), getCurrentException())
|
|
|
|
|
|
|
|
|
|
proc extractValidJwt*(ctx: ApiAuthContext, req: Request): 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.
|
|
|
|
|
##
|
|
|
|
|
## 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.
|
|
|
|
|
##
|
|
|
|
|
## - 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.
|
|
|
|
|
## - `${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.
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if headers(req).hasKey("Authorization"):
|
|
|
|
|
# Using a Bearer token.
|
|
|
|
|
result = toJWT(headers(req)["Authorization"][7..^1])
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
# Using a user/session cookie pair
|
|
|
|
|
let userCookieName = ctx.cookiePrefix & "-user"
|
|
|
|
|
let sessionCookieName = ctx.cookiePrefix & "-session"
|
|
|
|
|
|
|
|
|
|
if not cookies(req).hasKey(userCookieName):
|
|
|
|
|
failAuth "missing cookie '$#'" % userCookieName
|
|
|
|
|
if not cookies(req).hasKey(sessionCookieName):
|
|
|
|
|
failAuth "missing cookie '$#'" % sessionCookieName
|
|
|
|
|
|
|
|
|
|
let userVal = cookies(req)[userCookieName]
|
|
|
|
|
let sessionVal = cookies(req)[sessionCookieName]
|
|
|
|
|
result = toJWT(userVal & "." & sessionVal)
|
|
|
|
|
|
|
|
|
|
# Because this is a web session, check that the CSRF is present and
|
|
|
|
|
# matches.
|
|
|
|
|
if not headers(req).hasKey("X-CSRF-TOKEN") or
|
|
|
|
|
not result.claims["csrfToken"].isSome:
|
|
|
|
|
failAuth "missing CSRF token"
|
|
|
|
|
|
|
|
|
|
if headers(req)["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
|
|
|
|
|
failAuth "invalid CSRF token"
|
|
|
|
|
|
|
|
|
|
ctx.validateJwt(result)
|
|
|
|
|
except:
|
|
|
|
|
failAuth(getCurrentExceptionMsg(), getCurrentException())
|
|
|
|
|
|
|
|
|
|
proc createSignedJWT*(ctx: ApiAuthContext, claims: JsonNode, kid: string): JWT =
|
|
|
|
|
## 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
|
|
|
|
|
## chooses the signing algorithm,
|
|
|
|
|
|
|
|
|
|
let foundKeys = ctx.issuerKeys[ctx.issuer]
|
|
|
|
|
.filterIt(it.kid.isSome and it.kid.get == kid)
|
|
|
|
|
|
|
|
|
|
if foundKeys.len != 1:
|
|
|
|
|
failAuth "cannot create signed JWT, unable to find key for kid " & kid
|
|
|
|
|
|
|
|
|
|
let sigKey = foundKeys[0]
|
|
|
|
|
|
|
|
|
|
result = createSignedJwt(
|
|
|
|
|
initJoseHeader(%*{
|
|
|
|
|
"alg": sigKey.alg.get,
|
|
|
|
|
"typ": "JWT",
|
|
|
|
|
"kid": sigKey.kid.get }),
|
|
|
|
|
initJwtClaims(claims),
|
|
|
|
|
sigKey)
|
|
|
|
|
|
|
|
|
|
proc newApiAccessToken*(ctx: ApiAuthContext, sub: string, duration = 1.hours): JWT =
|
|
|
|
|
result = ctx.createSignedJWT(
|
|
|
|
|
%*{
|
|
|
|
|
"sub": sub,
|
|
|
|
|
"iss": ctx.issuer,
|
|
|
|
|
"iat": now().utc.toTime.toUnix.int,
|
|
|
|
|
"aud": ctx.issuer,
|
|
|
|
|
"exp": (now() + duration).utc.toTime.toUnix.int },
|
|
|
|
|
ctx.signingKid)
|