# Kong recipe (DB-less, open-source Kong 3.9, the last OSS image): the
# protected API is reached only with a valid TrustPin attestation token.
# The check is trustpin_attest.lua, embedded below for the pre-function
# plugin; keep the two in step (examples/gateways/kong/render.py rewrites
# this file from the .lua). Kong must run with untrusted_lua = on.
_format_version: "3.0"
services:
  - name: api
    url: http://issuer:18300        # your API
    routes:
      - name: api
        paths: ["/"]
    plugins:
      - name: pre-function
        config:
          access:
            - |
              -- TrustPin attestation check for Kong (open-source), run by the
              -- pre-function plugin in the access phase. A request continues to the
              -- upstream only when its TrustPin-Attestation header carries a valid access
              -- token (examples/verify-token/README.md has the rules: ES256 by kid from the
              -- verifier's JWKS, typ, iss, aud, exp with 60 s leeway, tp.prj, no debug
              -- tokens, bind = base64url(SHA-256(Authorization))).
              --
              -- Uses only libraries Kong ships: lua-resty-openssl, lua-resty-http, cjson
              -- and ngx.base64. pre-function needs `untrusted_lua = on` to require them.
              local JWKS_URL = "http://issuer:18300/.well-known/jwks.json"  -- your verifier
              local ISSUER = "https://attest.example.com"
              local AUDIENCE = "tprn::project::org_example::prj_example"
              -- Expected tp.prj, tprn::project::<organizationId>::<projectId>. "" means
              -- AUDIENCE when it has that form (the default), otherwise no project check.
              local PROJECT = ""
              -- true refuses tokens without bind (attestation policy tokens.binding: required).
              local REQUIRE_BIND = false
              local LEEWAY = 60
              local JWKS_MAX_AGE = 300  -- seconds a fetched JWKS is cached
              local JWKS_COOLDOWN = 30  -- at most one refetch for an unknown kid per 30 s
              local JWKS_TIMEOUT = 5000 -- ms before a JWKS fetch gives up

              local cjson = require("cjson.safe")
              local http = require("resty.http")
              local pkey = require("resty.openssl.pkey")
              local digest = require("resty.openssl.digest")
              local b64 = require("ngx.base64")

              local function refuse(why)
                kong.log.warn("trustpin attestation rejected: ", why)
                return kong.response.exit(401, { message = "attestation rejected" })
              end

              local function fetch_jwks()
                local httpc = http.new()
                httpc:set_timeouts(JWKS_TIMEOUT, JWKS_TIMEOUT, JWKS_TIMEOUT)
                local res, err = httpc:request_uri(JWKS_URL, { method = "GET" })
                if not res or res.status ~= 200 then
                  return nil, "jwks fetch failed: " .. tostring(err or res.status)
                end
                local doc = cjson.decode(res.body)
                local keys = {}
                for _, jwk in ipairs((doc and doc.keys) or {}) do
                  if jwk.kty == "EC" and jwk.crv == "P-256" and jwk.kid then
                    keys[jwk.kid] = cjson.encode({ kty = "EC", crv = "P-256", x = jwk.x, y = jwk.y })
                  end
                end
                return keys
              end

              -- The JWKS lives in Kong's cache (shared by the workers; concurrent misses
              -- wait for one fetch) for JWKS_MAX_AGE; when a refetch fails, the previous
              -- copy is kept for another JWKS_COOLDOWN. An unknown kid (key rotation)
              -- refetches under a new cache key, at most once per JWKS_COOLDOWN across all
              -- workers (the atomic add on the `kong` shared dict); a kid still unknown is
              -- refused without fetching. (pre-function cannot invalidate a cache entry in
              -- DB-less mode, hence the generation number in the key.)
              local function key_for(kid)
                local dict = ngx.shared.kong
                local opts = { ttl = JWKS_MAX_AGE, resurrect_ttl = JWKS_COOLDOWN }
                local gen = dict:get("trustpin_attest_jwks_gen") or 0
                local keys, err = kong.cache:get("trustpin_attest_jwks:" .. gen, opts, fetch_jwks)
                if keys and not keys[kid] and dict:add("trustpin_attest_jwks_cooldown", true, JWKS_COOLDOWN) then
                  gen = dict:incr("trustpin_attest_jwks_gen", 1, 0)
                  keys, err = kong.cache:get("trustpin_attest_jwks:" .. gen, opts, fetch_jwks)
                end
                if not keys then
                  return nil, err
                end
                if not keys[kid] then
                  return nil, "unknown kid"
                end
                return pkey.new(keys[kid], { format = "JWK" })
              end

              -- ES256 signatures are raw r||s (64 bytes); OpenSSL wants DER.
              local function der_signature(raw)
                local function int(b)
                  b = b:gsub("^%z+", "")
                  if #b == 0 then b = "\0" end
                  if b:byte(1) >= 0x80 then b = "\0" .. b end
                  return "\2" .. string.char(#b) .. b
                end
                local body = int(raw:sub(1, 32)) .. int(raw:sub(33, 64))
                return "\48" .. string.char(#body) .. body
              end

              local function check()
                local token = kong.request.get_header("TrustPin-Attestation")
                if not token then
                  return "no TrustPin-Attestation header"
                end
                local h64, p64, s64 = token:match("^([%w_-]+)%.([%w_-]+)%.([%w_-]*)$")
                if not h64 then
                  return "malformed token"
                end
                local header = cjson.decode(b64.decode_base64url(h64) or "")
                if type(header) ~= "table" or header.alg ~= "ES256" then
                  return "alg must be ES256"
                end
                if header.typ ~= "trustpin-attest+jwt" then
                  return "wrong typ"
                end
                local sig = b64.decode_base64url(s64)
                if not sig or #sig ~= 64 then
                  return "bad signature encoding"
                end
                local key, err = key_for(header.kid)
                if not key then
                  return err
                end
                local ok = key:verify(der_signature(sig), h64 .. "." .. p64, "sha256")
                if not ok then
                  return "bad signature"
                end
                local c = cjson.decode(b64.decode_base64url(p64) or "")
                if type(c) ~= "table" then
                  return "malformed payload"
                end
                if c.iss ~= ISSUER then
                  return "wrong iss"
                end
                local aud_ok = c.aud == AUDIENCE
                if type(c.aud) == "table" then
                  for _, a in ipairs(c.aud) do aud_ok = aud_ok or a == AUDIENCE end
                end
                if not aud_ok then
                  return "wrong aud"
                end
                if type(c.exp) ~= "number" or c.exp + LEEWAY < ngx.time() then
                  return "expired"
                end
                local project = PROJECT
                if project == "" and AUDIENCE:sub(1, 15) == "tprn::project::" then
                  project = AUDIENCE
                end
                if project ~= "" and (type(c.tp) ~= "table" or c.tp.prj ~= project) then
                  return "wrong project"
                end
                if type(c.tp) == "table" and c.tp.env == "debug" then
                  return "debug token"
                end
                if c.bind == nil and REQUIRE_BIND then
                  return "unbound token"
                end
                if c.bind ~= nil then
                  local auth = kong.request.get_header("Authorization") or ""
                  local sum = digest.new("sha256"):final(auth)
                  if c.bind ~= b64.encode_base64url(sum) then
                    return "bound to another session"
                  end
                end
                kong.service.request.set_header("X-TrustPin-Sub", c.sub or "")
                return nil
              end

              local why = check()
              if why then
                return refuse(why)
              end
