Make API auth contexts safe to copy across threads
This commit is contained in:
+131
@@ -0,0 +1,131 @@
|
||||
import std/[json, options, unittest]
|
||||
import jwt_full
|
||||
import buffoonery/auth
|
||||
|
||||
const testIssuer = "https://issuer.example"
|
||||
|
||||
proc keyNode(kid = "original"): JsonNode =
|
||||
%*{"kty": "oct", "alg": "HS256", "kid": kid,
|
||||
"k": "c2VjcmV0", "metadata": {"labels": ["original"]}}
|
||||
|
||||
proc newContext(): ApiAuthContext =
|
||||
initApiAuthContext("example.com", "test", @[testIssuer], testIssuer,
|
||||
@[testIssuer], "original", @[initJwk(keyNode())])
|
||||
|
||||
proc tokenFor(kid = "original", issuer = testIssuer): JWT =
|
||||
# Lookup only needs the header and issuer; signing is tested separately.
|
||||
createSignedJwt(initJoseHeader(%*{"alg": "HS256", "kid": kid}),
|
||||
initJwtClaims(%*{"iss": issuer}), initJwk(keyNode(kid)))
|
||||
|
||||
when compileOption("threads"):
|
||||
type WorkerInput = tuple[ctx: ApiAuthContext, ok: ptr bool]
|
||||
|
||||
proc useContext(input: WorkerInput) {.thread.} =
|
||||
var ctx = input.ctx
|
||||
try:
|
||||
ctx.validAudiences[0] = "worker"
|
||||
let key = ctx.findSigningKey(tokenFor(), false)
|
||||
key["metadata"].get["labels"][0].str = "worker"
|
||||
ctx.addSigningKeys(testIssuer, @[initJwk(keyNode("worker"))])
|
||||
input.ok[] = ctx.findSigningKey(tokenFor("worker"), false).kid.get == "worker"
|
||||
except CatchableError:
|
||||
input.ok[] = false
|
||||
|
||||
suite "auth context value semantics":
|
||||
test "copies own their configuration and signing-key cache":
|
||||
var original = newContext()
|
||||
var copied = original
|
||||
copied.appDomain[0] = 'E'
|
||||
copied.validAudiences[0][0] = 'H'
|
||||
copied.trustedIssuers.add "https://other.example"
|
||||
copied.addSigningKeys(testIssuer, @[initJwk(keyNode("replacement"))])
|
||||
|
||||
check original.appDomain == "example.com"
|
||||
check original.validAudiences == @[testIssuer]
|
||||
check original.trustedIssuers == @[testIssuer]
|
||||
check original.findSigningKey(tokenFor(), false).kid.get == "original"
|
||||
check copied.findSigningKey(tokenFor("replacement"), false).kid.get == "replacement"
|
||||
expect AuthError:
|
||||
discard copied.findSigningKey(tokenFor(), false)
|
||||
expect AuthError:
|
||||
discard original.findSigningKey(tokenFor("replacement"), false)
|
||||
|
||||
test "constructor and added keys do not retain caller-owned JSON":
|
||||
let node = keyNode()
|
||||
var ctx = initApiAuthContext("example.com", "test", @[testIssuer],
|
||||
testIssuer, @[testIssuer], "original", @[initJwk(node)])
|
||||
node["metadata"]["labels"][0].str = "changed"
|
||||
check ctx.findSigningKey(tokenFor(), false)["metadata"].get["labels"][0].getStr == "original"
|
||||
|
||||
let added = keyNode("added")
|
||||
ctx.addSigningKeys(testIssuer, @[initJwk(added)])
|
||||
added["metadata"]["labels"][0].str = "changed"
|
||||
check ctx.findSigningKey(tokenFor("added"), false)["metadata"].get["labels"][0].getStr == "original"
|
||||
|
||||
test "returned key metadata is independent of both context copies":
|
||||
var original = newContext()
|
||||
var copied = original
|
||||
let key = copied.findSigningKey(tokenFor(), false)
|
||||
key["metadata"].get["labels"][0].str = "changed"
|
||||
check original.findSigningKey(tokenFor(), false)["metadata"].get["labels"][0].getStr == "original"
|
||||
check copied.findSigningKey(tokenFor(), false)["metadata"].get["labels"][0].getStr == "original"
|
||||
|
||||
test "default contexts accept keys and reject missing keys without fetching":
|
||||
var ctx: ApiAuthContext
|
||||
expect AuthError:
|
||||
discard ctx.findSigningKey(tokenFor(), false)
|
||||
ctx.addSigningKeys(testIssuer, @[initJwk(keyNode())])
|
||||
check ctx.findSigningKey(tokenFor(), false).kid.get == "original"
|
||||
|
||||
test "invalid key updates leave the existing cache intact":
|
||||
var ctx = newContext()
|
||||
let invalid = keyNode("invalid")
|
||||
invalid.delete("alg")
|
||||
expect AuthError:
|
||||
ctx.addSigningKeys(testIssuer, @[initJwk(invalid)])
|
||||
check ctx.findSigningKey(tokenFor(), false).kid.get == "original"
|
||||
|
||||
test "immutable contexts can sign tokens that mutable copies validate":
|
||||
let original = newContext()
|
||||
let token = original.newApiAccessToken("user")
|
||||
var copied = original
|
||||
copied.validateJWT(token)
|
||||
check token.claims.sub.get == "user"
|
||||
|
||||
test "cached keys preserve RSA and EC variants":
|
||||
var ctx: ApiAuthContext
|
||||
let nodes = @[
|
||||
%*{"kty": "RSA", "alg": "RS256", "kid": "rsa-public",
|
||||
"n": "AQAB", "e": "AQAB"},
|
||||
%*{"kty": "RSA", "alg": "RS256", "kid": "rsa-private",
|
||||
"n": "AQAB", "e": "AQAB", "d": "Ag", "p": "Aw", "q": "BQ",
|
||||
"oth": [{"r": "Bw", "d": "Ag", "t": "Aw"}]},
|
||||
%*{"kty": "EC", "alg": "ES256", "kid": "ec-public",
|
||||
"crv": "P-256", "x": "AQ", "y": "Ag"},
|
||||
%*{"kty": "EC", "alg": "ES256", "kid": "ec-private",
|
||||
"crv": "P-256", "x": "AQ", "y": "Ag", "d": "Aw"}]
|
||||
for node in nodes:
|
||||
let key = initJwk(node)
|
||||
ctx.addSigningKeys(testIssuer, @[key])
|
||||
let restored = ctx.findSigningKey(tokenFor(key.kid.get), false)
|
||||
check restored.keyKind == key.keyKind
|
||||
for name, value in node.pairs:
|
||||
check restored[name].get == value
|
||||
case key.keyKind
|
||||
of RsaPublic: check restored.rsaPub == key.rsaPub
|
||||
of RsaPrivate: check restored.rsaPrv == key.rsaPrv
|
||||
of EcPublic: check restored.ecPub == key.ecPub
|
||||
of EcPrivate: check restored.ecPrv == key.ecPrv
|
||||
of Octet: discard
|
||||
|
||||
when compileOption("threads"):
|
||||
test "workers mutate independent context copies":
|
||||
var original = newContext()
|
||||
var workers: array[2, Thread[WorkerInput]]
|
||||
var succeeded: array[2, bool]
|
||||
for i in 0 ..< workers.len:
|
||||
createThread(workers[i], useContext, (original, addr succeeded[i]))
|
||||
joinThreads(workers)
|
||||
check succeeded == [true, true]
|
||||
check original.validAudiences == @[testIssuer]
|
||||
check original.findSigningKey(tokenFor(), false)["metadata"].get["labels"][0].getStr == "original"
|
||||
|
||||
Reference in New Issue
Block a user