TASK-16: Lua Scripting for Inbound and Outbound Auth
Context
Implement Lua scripting support for both inbound authentication (custom token validation logic) and outbound authentication (custom token acquisition for upstream APIs). Uses github.com/yuin/gopher-lua with a pooled, sandboxed, pre-compiled VM pattern proven in KrakenD. This enables operators to implement proprietary authentication flows that no standard OAuth2 strategy supports.
No stubs.
Active development: No backward compatibility.
Prerequisites
- TASK-09 (inbound auth interface and middleware)
- TASK-10 (outbound auth interface and factory)
Spec References
SPEC.md §15 Authentication Layer (Lua sections)
SPEC.md AC-15 (inbound Lua)
SPEC.md AC-18.6 (outbound Lua)
What to Implement
internal/auth/inbound/lua.go
package inbound
// LuaValidator implements TokenValidator using a sandboxed gopher-lua VM pool.
// The Lua script must define: function check_auth(token)
// Returns: allowed (bool), status (int), extra_headers (table), error_msg (string)
type LuaValidator struct {
proto *lua.FunctionProto // pre-compiled bytecode, shared across all VMs
pool sync.Pool // pool of *lua.LState
timeout time.Duration
}
func NewLuaValidator(cfg config.LuaAuthConfig) (*LuaValidator, error)
func (v *LuaValidator) ValidateToken(ctx context.Context, token string) (*TokenInfo, error)
NewLuaValidator:
- Read the Lua script from
cfg.ScriptPath
- Pre-compile to bytecode: parse with
parse.Parse(reader, name) from github.com/yuin/gopher-lua/parse, then compile with lua.Compile(chunk, name) → *lua.FunctionProto
- Initialise
sync.Pool{New: func() any { return newSandboxedVM() }}
newSandboxedVM:
func newSandboxedVM() *lua.LState {
L := lua.NewState(lua.Options{SkipOpenLibs: true, CallStackSize: 64})
// Open ONLY safe stdlib modules
lua.OpenBase(L)
lua.OpenTable(L)
lua.OpenString(L)
lua.OpenMath(L)
// Do NOT open: OpenIo, OpenOs, OpenPackage, OpenDebug, OpenCoroutine
return L
}
ValidateToken:
func (v *LuaValidator) ValidateToken(ctx context.Context, token string) (*TokenInfo, error) {
L := v.pool.Get().(*lua.LState)
defer func() {
L.SetContext(nil) // must clear before returning to pool
v.pool.Put(L)
}()
// Apply timeout
tctx, cancel := context.WithTimeout(ctx, v.timeout)
defer cancel()
L.SetContext(tctx)
// Load pre-compiled function into this VM
fn := L.NewFunctionFromProto(v.proto)
L.Push(fn)
L.Push(lua.LString(token))
if err := L.PCall(1, 4, nil); err != nil {
return nil, fmt.Errorf("lua check_auth: %w", err)
}
// Pop 4 return values (in reverse order from stack)
errMsg := L.ToString(-1); L.Pop(1)
// extra_headers: L.ToTable(-1)
extraHeaders := luaTableToMap(L, L.ToTable(-1)); L.Pop(1)
status := int(L.ToInt(-1)); L.Pop(1)
allowed := L.ToBool(-1); L.Pop(1)
if !allowed {
return nil, fmt.Errorf("lua auth denied (status %d): %s", status, errMsg)
}
info := &TokenInfo{Subject: "lua-authenticated", Extra: make(map[string]any)}
for k, v := range extraHeaders {
info.Extra["header:"+k] = v
}
return info, nil
}
luaTableToMap converts a *lua.LTable to map[string]string.
The extra_headers from the Lua script's return value must be injected into the request context so downstream middleware can add them to the response. Store them in context with a typed key.
internal/auth/outbound/lua.go
package outbound
// LuaProvider implements OutboundTokenProvider using a Lua script.
// The script must define: function get_upstream_token(upstream, cached_token, cached_expiry)
// Returns: token (string), expiry_unix (int), raw_headers (table), error_msg (string)
type LuaProvider struct {
upstreamName string
proto *lua.FunctionProto
pool sync.Pool
timeout time.Duration
cache struct {
mu sync.Mutex
token string
expiry int64 // unix timestamp; 0 = no caching
rawHeaders map[string]string
}
}
func NewLuaProvider(upstreamName string, cfg config.LuaOutboundConfig) (*LuaProvider, error)
func (p *LuaProvider) Token(ctx context.Context) (string, error)
func (p *LuaProvider) RawHeaders(ctx context.Context) (map[string]string, error)
Token and RawHeaders share cache logic:
func (p *LuaProvider) ensureToken(ctx context.Context) error {
p.cache.mu.Lock()
defer p.cache.mu.Unlock()
now := time.Now().Unix()
if p.cache.expiry != 0 && now < p.cache.expiry {
return nil // cache still valid
}
// Call Lua script
token, expiry, rawHeaders, err := p.callLua(ctx, p.cache.token, p.cache.expiry)
if err != nil { return err }
p.cache.token = token
p.cache.expiry = expiry
p.cache.rawHeaders = rawHeaders
return nil
}
callLua returns the four values from the Lua function. If expiry == 0, the result is not cached (script called on every request).
RawHeaders returns p.cache.rawHeaders if non-empty (after calling ensureToken).
Token returns p.cache.token (after calling ensureToken).
If len(p.cache.rawHeaders) > 0, Token should return "" and RawHeaders returns the map. The AuthTransport from TASK-10 already handles this precedence correctly.
Factory integration
Update internal/auth/inbound/middleware.go to build a LuaValidator when strategy == "lua".
Update internal/auth/outbound/factory.go to build a LuaProvider when strategy == "lua".
Config already complete
The LuaAuthConfig for inbound and LuaOutboundConfig for outbound are already in the config structs from TASK-09/10. Ensure Timeout has a default of 500ms if not set.
Unit Tests
Create internal/auth/inbound/lua_test.go and internal/auth/outbound/lua_test.go (no build tag):
These tests use real Lua scripts in temp files — no containers needed.
Inbound tests:
- Script that returns
true, 200, {}, "" for any token → ValidateToken returns TokenInfo
- Script that returns
false, 401, {}, "forbidden" → ValidateToken returns error
- Script that loops forever → timeout is enforced (use 10ms timeout), returns error
- Script with syntax error →
NewLuaValidator returns compile error
- Script tries
os.getenv("HOME") → os is nil (sandboxed), script errors gracefully
- Script tries
io.open("/etc/passwd") → io is nil, script errors gracefully
- Script returns non-empty
extra_headers → TokenInfo.Extra contains them
- Concurrent calls: 20 goroutines call
ValidateToken simultaneously → no data races (run with -race)
Outbound tests:
- Script returns token with future expiry → subsequent call uses cache (assert script called only once)
- Script returns
expiry_unix == 0 → script called on every invocation
- Script returns
raw_headers → RawHeaders() returns them; Token() returns ""
- Timeout enforcement: script sleeps 1 second, timeout is 10ms → error returned
Integration Test
Create tests/integration/lua_auth_test.go with build tag //go:build integration and package integration_test.
This test does NOT import any internal/ packages. The proxy runs as a Docker container built via proxyContainerRequest(). All assertions go through the MCP protocol and HTTP endpoints.
WireMock serves as the upstream API on a shared Docker network. Lua scripts are mounted into the proxy container via testcontainers.ContainerFile.
Test: TestInboundLuaAuthAllowsValidToken
- Write Lua script to a temp file: returns
true for token "valid-token", false otherwise
- Start proxy container with Lua script and config mounted via
testcontainers.ContainerFile on shared network
- Start WireMock container on shared network
- Connect MCP client to proxy container's
/mcp endpoint with Authorization: Bearer valid-token → succeeds, tools/list returns tools
- Connect MCP client with
Authorization: Bearer wrong-token → 401 error
Test: TestInboundLuaExtraHeadersInjected
- Lua script returns
extra_headers = {["X-User-ID"] = "user-42"}
- Mount Lua script and config into proxy container via
testcontainers.ContainerFile
- Assert that the extra header is accessible (e.g., logged or passed to upstream — verify via WireMock request journal that the header arrived at WireMock)
Test: TestOutboundLuaTokenInjected
- WireMock upstream expects
X-Custom-Auth: dynamic-token-{timestamp}
- Write Lua script that generates a token based on current time (returns expiry 1 second in the future)
- Mount Lua script and config into proxy container via
testcontainers.ContainerFile
- Connect MCP client, call MCP tool → assert WireMock received the correct custom auth header (check WireMock request journal via admin API)
- Wait 1.1 seconds, call again → script is re-invoked (cache expired) → new token (verify via WireMock request journal)
Test: TestOutboundLuaRawHeadersInjected
- Lua script returns:
return "", 0, {["X-API-Key"] = "key123", ["X-Tenant"] = "acme"}, ""
- Mount Lua script and config into proxy container via
testcontainers.ContainerFile
- Call MCP tool, assert WireMock received both headers (check WireMock request journal via admin API)
Test: TestLuaSandboxPreventsFSAccess
- Lua script body:
io.open("/etc/passwd", "r")
- Mount Lua script and config into proxy container via
testcontainers.ContainerFile
- Proxy container should either fail to start or return auth errors at runtime when MCP calls are made (assert
IsError: true or HTTP error response)
Checks Before Committing
make check
make integration
Run unit tests with race detector:
go test -race -count=3 ./internal/auth/...
Acceptance Criteria
Definition of Done
make check exits 0. Integration tests pass. Unit tests pass with -race -count=3. The proxy can authenticate MCP clients using arbitrary Lua logic and can obtain upstream credentials from a Lua script, enabling completely custom proprietary authentication flows.
TASK-16: Lua Scripting for Inbound and Outbound Auth
Context
Implement Lua scripting support for both inbound authentication (custom token validation logic) and outbound authentication (custom token acquisition for upstream APIs). Uses
github.com/yuin/gopher-luawith a pooled, sandboxed, pre-compiled VM pattern proven in KrakenD. This enables operators to implement proprietary authentication flows that no standard OAuth2 strategy supports.No stubs.
Active development: No backward compatibility.
Prerequisites
Spec References
SPEC.md§15 Authentication Layer (Lua sections)SPEC.mdAC-15 (inbound Lua)SPEC.mdAC-18.6 (outbound Lua)What to Implement
internal/auth/inbound/lua.goNewLuaValidator:cfg.ScriptPathparse.Parse(reader, name)fromgithub.qkg1.top/yuin/gopher-lua/parse, then compile withlua.Compile(chunk, name)→*lua.FunctionProtosync.Pool{New: func() any { return newSandboxedVM() }}newSandboxedVM:ValidateToken:luaTableToMapconverts a*lua.LTabletomap[string]string.The
extra_headersfrom the Lua script's return value must be injected into the request context so downstream middleware can add them to the response. Store them in context with a typed key.internal/auth/outbound/lua.goTokenandRawHeadersshare cache logic:callLuareturns the four values from the Lua function. Ifexpiry == 0, the result is not cached (script called on every request).RawHeadersreturnsp.cache.rawHeadersif non-empty (after callingensureToken).Tokenreturnsp.cache.token(after callingensureToken).If
len(p.cache.rawHeaders) > 0,Tokenshould return""andRawHeadersreturns the map. TheAuthTransportfrom TASK-10 already handles this precedence correctly.Factory integration
Update
internal/auth/inbound/middleware.goto build aLuaValidatorwhenstrategy == "lua".Update
internal/auth/outbound/factory.goto build aLuaProviderwhenstrategy == "lua".Config already complete
The
LuaAuthConfigfor inbound andLuaOutboundConfigfor outbound are already in the config structs from TASK-09/10. EnsureTimeouthas a default of500msif not set.Unit Tests
Create
internal/auth/inbound/lua_test.goandinternal/auth/outbound/lua_test.go(no build tag):These tests use real Lua scripts in temp files — no containers needed.
Inbound tests:
true, 200, {}, ""for any token →ValidateTokenreturnsTokenInfofalse, 401, {}, "forbidden"→ValidateTokenreturns errorNewLuaValidatorreturns compile erroros.getenv("HOME")→osis nil (sandboxed), script errors gracefullyio.open("/etc/passwd")→iois nil, script errors gracefullyextra_headers→ TokenInfo.Extra contains themValidateTokensimultaneously → no data races (run with-race)Outbound tests:
expiry_unix == 0→ script called on every invocationraw_headers→RawHeaders()returns them;Token()returns""Integration Test
Create
tests/integration/lua_auth_test.gowith build tag//go:build integrationand packageintegration_test.This test does NOT import any
internal/packages. The proxy runs as a Docker container built viaproxyContainerRequest(). All assertions go through the MCP protocol and HTTP endpoints.WireMock serves as the upstream API on a shared Docker network. Lua scripts are mounted into the proxy container via
testcontainers.ContainerFile.Test:
TestInboundLuaAuthAllowsValidTokentruefor token"valid-token",falseotherwisetestcontainers.ContainerFileon shared network/mcpendpoint withAuthorization: Bearer valid-token→ succeeds,tools/listreturns toolsAuthorization: Bearer wrong-token→ 401 errorTest:
TestInboundLuaExtraHeadersInjectedextra_headers = {["X-User-ID"] = "user-42"}testcontainers.ContainerFileTest:
TestOutboundLuaTokenInjectedX-Custom-Auth: dynamic-token-{timestamp}testcontainers.ContainerFileTest:
TestOutboundLuaRawHeadersInjectedreturn "", 0, {["X-API-Key"] = "key123", ["X-Tenant"] = "acme"}, ""testcontainers.ContainerFileTest:
TestLuaSandboxPreventsFSAccessio.open("/etc/passwd", "r")testcontainers.ContainerFileIsError: trueor HTTP error response)Checks Before Committing
Run unit tests with race detector:
go test -race -count=3 ./internal/auth/...Acceptance Criteria
check_auth(token)Lua function called for requests whereAuthRequired == trueio,os,package,debugstdlib disabled in VMlua.timeoutenforced viacontext.WithTimeoutfalsefirst return value → HTTP 401 with fourth return as message(upstream, cached_token, cached_expiry); result cached untilexpiry_unix;expiry_unix == 0disables caching;raw_headerstakes precedence overtokenforAuthorization-raceflag)Definition of Done
make checkexits 0. Integration tests pass. Unit tests pass with-race -count=3. The proxy can authenticate MCP clients using arbitrary Lua logic and can obtain upstream credentials from a Lua script, enabling completely custom proprietary authentication flows.