Skip to content

TASK-16: Lua Scripting for Inbound and Outbound Auth #19

Description

@gaarutyunov

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:

  1. Read the Lua script from cfg.ScriptPath
  2. 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
  3. 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_headersRawHeaders() 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

  • AC-15.1: check_auth(token) Lua function called for requests where AuthRequired == true
  • AC-15.2: io, os, package, debug stdlib disabled in VM
  • AC-15.3: lua.timeout enforced via context.WithTimeout
  • AC-15.4: false first return value → HTTP 401 with fourth return as message
  • AC-15.5: Lua script pre-compiled to bytecode at startup; bytecode reused across pooled VMs
  • AC-18.6: Outbound Lua script called with (upstream, cached_token, cached_expiry); result cached until expiry_unix; expiry_unix == 0 disables caching; raw_headers takes precedence over token for Authorization
  • VM pool correctly resets context between uses (no context leak between calls)
  • No data races under concurrent load (verified by -race flag)
  • All unit and integration tests pass

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions