Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions spec/unit/features/wrapper.feature
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ Feature: LLM Proxy Wrapper Mode unit behavior
And the upstream_key is "sk-abc123"
And JWT claims have sub "user123"

Scenario: Missing colon separator returns composite_key_invalid
Scenario: Plain upstream key without JWT succeeds
Given the nginx mock is set up
And an auth header "Bearer justajwt"
And an auth header "Bearer sk-proj-abc123"
When parse_composite_bearer is called
Then parsing succeeds
And the upstream_key is "sk-proj-abc123"
And jwt_part is nil

Scenario: Bearer with only a colon (empty JWT) returns composite_key_invalid
Given the nginx mock is set up
And an auth header "Bearer :sk-proj-abc123"
When parse_composite_bearer is called
Then parsing fails with reason "composite_key_invalid"

Expand Down Expand Up @@ -178,6 +186,14 @@ Feature: LLM Proxy Wrapper Mode unit behavior
Then upstream url contains "api.openai.com"
And ngx exit was not called

Scenario: Plain upstream key without JWT allows request
Given the nginx mock is set up for access_handler
And request auth header is "Bearer sk-proj-plainkey"
And request path is "/openai/v1/chat/completions"
When access_handler is called
Then upstream url contains "api.openai.com"
And ngx exit was not called

Scenario: Valid composite token to unknown path returns 404
Given the nginx mock is set up for access_handler
And request auth header is "Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyMTIzIn0.:sk-abc123"
Expand Down
5 changes: 5 additions & 0 deletions spec/unit/wrapper_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ runner:then_("^the upstream_key is \"(.-)\"$", function(ctx, key)
assert.equals(key, ctx.parsed.upstream_key)
end)

runner:then_("^jwt_part is nil$", function(ctx)
assert.is_not_nil(ctx.parsed)
assert.is_nil(ctx.parsed.jwt_part)
end)

runner:then_("^JWT claims have sub \"(.-)\"$", function(ctx, sub)
assert.is_not_nil(ctx.parsed)
assert.is_not_nil(ctx.parsed.claims)
Expand Down
26 changes: 20 additions & 6 deletions src/fairvisor/wrapper.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
-- LLM Proxy Wrapper Mode — feature 019
-- Composite Bearer token: "Authorization: Bearer CLIENT_JWT:UPSTREAM_KEY"
-- Composite Bearer token: "Authorization: Bearer CLIENT_JWT:UPSTREAM_KEY" (JWT optional)
-- Plain Bearer token: "Authorization: Bearer UPSTREAM_KEY" (anonymous, no JWT)
-- Provider registry maps path prefix → upstream host + auth scheme.
-- Provider-native error bodies and streaming cutoff formats.

Expand Down Expand Up @@ -239,9 +240,12 @@ function _M.init(deps)
end

-- parse_composite_bearer(auth_header)
-- Returns: ok_table {jwt_part, upstream_key} or nil, reason_code
-- Returns: ok_table {jwt_part, upstream_key, claims} or nil, reason_code
--
-- Composite token format: "Bearer CLIENT_JWT:UPSTREAM_KEY"
-- Composite token format: "Bearer CLIENT_JWT:UPSTREAM_KEY" (JWT present)
-- Plain token format: "Bearer UPSTREAM_KEY" (JWT absent, anonymous tenant)
-- JWT is optional. If no colon is present, the whole token is the upstream key
-- and jwt_part is nil; claims is an empty table.
-- Split is at the FIRST colon in the token portion.
function _M.parse_composite_bearer(auth_header)
if type(auth_header) ~= "string" then
Expand All @@ -257,13 +261,23 @@ function _M.parse_composite_bearer(auth_header)
return nil, "composite_key_invalid"
end

-- Find first colon to split JWT:KEY
-- JWT is optional. If no colon present, treat the whole token as the
-- upstream key (anonymous tenant — no JWT identity).
local colon_pos = string_find(token, ":", 1, true)
if not colon_pos or colon_pos <= 1 then
if not colon_pos then
return {
jwt_part = nil,
upstream_key = token,
claims = {},
}
end

-- Colon present but at position 1 means empty jwt_part — invalid.
if colon_pos <= 1 then
return nil, "composite_key_invalid"
end

local jwt_part = string_sub(token, 1, colon_pos - 1)
local jwt_part = string_sub(token, 1, colon_pos - 1)
local upstream_key = string_sub(token, colon_pos + 1)

if jwt_part == "" then
Expand Down
Loading