Skip to content

Commit 28927d5

Browse files
committed
fix(elixir): apply configured authenticator instead of silently dropping it
BaseApi read the authenticator's auth_headers/query_params/cookie_params as struct fields gated on Map.has_key?, but every authenticator exposes them as behaviour callbacks, not struct keys. The guard was always false, so a configured Bearer/api-key/cookie credential never reached the wire and every authenticated call went out unauthenticated. Dispatch the callbacks through the authenticator's module (function_exported?-guarded), keeping a plain-map fast-path for map-shaped auth. Add a real-struct auth-applied regression test (and a configured-authenticator guard) to all 12 SDK test suites; the other 11 already applied auth correctly.
1 parent 4f3262e commit 28927d5

28 files changed

Lines changed: 1271 additions & 125 deletions

File tree

src/main/resources/templates/csharp/test/Api/PetApiTest.mustache

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,30 @@ public class PetApiTest
535535
Assert.Equal("Bearer per-call-token", client.CapturedHeaders["Authorization"]);
536536
}
537537
538+
[Fact]
539+
public async Task TestConfiguredAuthenticatorIsAppliedToSecuredOperation()
540+
{
541+
// AUTH-APPLIED regression guard (Wave A1): a configured BearerAuthenticator
542+
// must actually be applied to a SECURED operation's outbound request. This
543+
// mirrors the Elixir bug where a configured authenticator was silently
544+
// dropped and the request went out unauthenticated. Construct a client with
545+
// a real BearerAuthenticator (not a raw default header), issue a request
546+
// through the secured AddPet operation, and assert the wire request carried
547+
// the Authorization credential.
548+
var client = new HeaderCapturingApiClient();
549+
var config = Configuration.Builder()
550+
.BaseUrl("http://localhost")
551+
.Build();
552+
var api = new PetApi(client, config);
553+
var authenticator = new BearerAuthenticator("http://localhost", "applied-token");
554+
555+
var pet = new Pet("AppliedDog", new HashSet<string> { "http://example.com/p.jpg" }) { Id = 1L };
556+
await api.AddPetAsync(pet, new AddPetOptions { Auth = authenticator });
557+
558+
Assert.True(client.CapturedHeaders.ContainsKey("Authorization"));
559+
Assert.Equal("Bearer applied-token", client.CapturedHeaders["Authorization"]);
560+
}
561+
538562
[Fact]
539563
public async Task TestAddPetUsesConfiguredCredentialsWhenAuthOmitted()
540564
{

src/main/resources/templates/dart/test/base_api_test.mustache

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,47 @@ void main() {
407407
}
408408
});
409409

410+
/* auth-applied (WAVE A1 regression guard): a CONFIGURED authenticator
411+
* supplied to the client must actually be applied to a SECURED request —
412+
* the outbound call must carry the credential on the wire. This is the
413+
* fleet-wide guard against the Elixir bug where a configured authenticator
414+
* was silently dropped. Here a real BearerAuthenticator is configured on
415+
* the client, a secured operation (addPet) is invoked, and we assert the
416+
* outgoing Authorization header carried the bearer token. */
417+
test('configured authenticator is applied to a secured request', () async {
418+
String? receivedAuth;
419+
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
420+
server.listen((request) {
421+
receivedAuth = request.headers.value('authorization');
422+
request.response
423+
..statusCode = 200
424+
..headers.contentType = ContentType.json
425+
..write('{"id":1,"name":"Fido","photoUrls":[]}')
426+
..close();
427+
});
428+
429+
try {
430+
final config = ConfigurationBuilder()
431+
.baseUrl('http://localhost:${server.port}')
432+
.build();
433+
final api = PetApi(
434+
apiClient: DefaultApiClient(),
435+
config: config,
436+
authenticator: BearerAuthenticator(
437+
host: 'http://localhost:${server.port}',
438+
token: 'secret-token'));
439+
440+
await api.addPet(Pet(name: 'Fido', photoUrls: <String>{}),
441+
const AddPetOptions());
442+
443+
expect(receivedAuth, equals('Bearer secret-token'),
444+
reason:
445+
'configured authenticator must reach the wire on a secured op');
446+
} finally {
447+
await server.close();
448+
}
449+
});
450+
410451
test('returns null data for empty 200 response', () async {
411452
final client = DefaultApiClient();
412453
final resp = await client.sendRequest(

src/main/resources/templates/elixir/base_api.mustache

Lines changed: 76 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,9 @@ defmodule {{moduleName}}.Api.BaseApi do
7575
effective_auth = auth || Map.get(state, :authenticator)
7676

7777
query_params =
78-
if effective_auth && is_map(effective_auth) && Map.has_key?(effective_auth, :query_params) do
79-
Map.merge(query_params, effective_auth.query_params)
80-
else
81-
query_params
78+
case auth_credentials(effective_auth, :query_params) do
79+
nil -> query_params
80+
params -> Map.merge(query_params, params)
8281
end
8382

8483
query_string = build_query_string(query_params)
@@ -94,50 +93,49 @@ defmodule {{moduleName}}.Api.BaseApi do
9493
headers = Map.merge(headers, header_params)
9594

9695
headers =
97-
if effective_auth && is_map(effective_auth) && Map.has_key?(effective_auth, :auth_headers) do
98-
Map.merge(headers, effective_auth.auth_headers)
99-
else
100-
headers
96+
case auth_credentials(effective_auth, :auth_headers) do
97+
nil -> headers
98+
auth_headers -> Map.merge(headers, auth_headers)
10199
end
102100

103101
headers =
104-
if effective_auth && is_map(effective_auth) && Map.has_key?(effective_auth, :cookie_params) do
105-
cookies = effective_auth.cookie_params
106-
107-
if map_size(cookies) > 0 do
108-
# RFC 6265 — don't URL-encode cookie name/value; most cookie
109-
# parsers don't URL-decode, so `=` (base64 padding) would
110-
# arrive as literal `%3D` and break JWT/session cookies.
111-
# Validate and pass through raw instead.
112-
cookie_str = Enum.map_join(cookies, "; ", fn {k, v} ->
113-
name = to_string(k)
114-
value = to_string(v)
115-
unless name =~ ~r/\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/ do
116-
raise ArgumentError,
117-
"Cookie name '#{name}' contains characters forbidden by RFC 6265"
118-
end
119-
unless value =~ ~r/\A[!\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*\z/ do
120-
raise ArgumentError,
121-
"Cookie value for '#{name}' contains characters forbidden by RFC 6265"
122-
end
123-
"#{name}=#{value}"
124-
end)
102+
case auth_credentials(effective_auth, :cookie_params) do
103+
nil ->
104+
headers
125105

126-
existing = Map.get(headers, "Cookie")
106+
cookies ->
107+
if map_size(cookies) > 0 do
108+
# RFC 6265 — don't URL-encode cookie name/value; most cookie
109+
# parsers don't URL-decode, so `=` (base64 padding) would
110+
# arrive as literal `%3D` and break JWT/session cookies.
111+
# Validate and pass through raw instead.
112+
cookie_str = Enum.map_join(cookies, "; ", fn {k, v} ->
113+
name = to_string(k)
114+
value = to_string(v)
115+
unless name =~ ~r/\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/ do
116+
raise ArgumentError,
117+
"Cookie name '#{name}' contains characters forbidden by RFC 6265"
118+
end
119+
unless value =~ ~r/\A[!\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*\z/ do
120+
raise ArgumentError,
121+
"Cookie value for '#{name}' contains characters forbidden by RFC 6265"
122+
end
123+
"#{name}=#{value}"
124+
end)
127125

128-
cookie_value =
129-
if existing do
130-
"#{existing}; #{cookie_str}"
131-
else
132-
cookie_str
133-
end
126+
existing = Map.get(headers, "Cookie")
134127

135-
Map.put(headers, "Cookie", cookie_value)
136-
else
137-
headers
138-
end
139-
else
140-
headers
128+
cookie_value =
129+
if existing do
130+
"#{existing}; #{cookie_str}"
131+
else
132+
cookie_str
133+
end
134+
135+
Map.put(headers, "Cookie", cookie_value)
136+
else
137+
headers
138+
end
141139
end
142140

143141
headers = {{moduleName}}.TraceContextUtil.inject_trace_context(headers)
@@ -248,6 +246,42 @@ defmodule {{moduleName}}.Api.BaseApi do
248246
end
249247
end
250248

249+
# Resolve an authenticator's credentials for the given behaviour callback
250+
# (:auth_headers, :query_params, or :cookie_params). Authenticators are
251+
# {{moduleName}}.Auth.Authenticator behaviours: the credential providers are
252+
# CALLBACK FUNCTIONS on the authenticator's module, not data fields on the
253+
# struct, so they are dispatched through `effective_auth.__struct__.<cb>/1`
254+
# (guarded by `function_exported?/3`, mirroring how `send_request` and the
255+
# client's `set_api_client` are invoked). Returns nil when there is no
256+
# authenticator or it does not implement the callback, so callers can skip
257+
# the merge entirely. Reading these as struct fields would silently drop the
258+
# credential — every real authenticator defstruct holds only its secret
259+
# material (e.g. [:host, :token]), never an :auth_headers key.
260+
defp auth_credentials(effective_auth, callback)
261+
when is_map(effective_auth) and is_atom(callback) do
262+
case effective_auth do
263+
%{__struct__: mod} ->
264+
# Real authenticators are behaviour structs: the credential providers
265+
# are CALLBACK FUNCTIONS on the module, dispatched via the struct's
266+
# module (guarded so a struct that doesn't implement the callback
267+
# simply contributes nothing).
268+
if function_exported?(mod, callback, 1) do
269+
apply(mod, callback, [effective_auth])
270+
else
271+
nil
272+
end
273+
274+
plain_map ->
275+
# Plain-map fast-path: a map-shaped auth carries its credentials as
276+
# data fields keyed by the callback name (e.g. %{auth_headers: %{...}}).
277+
# Supported for lightweight/ad-hoc auth injection alongside the
278+
# behaviour-struct form above.
279+
Map.get(plain_map, callback)
280+
end
281+
end
282+
283+
defp auth_credentials(_effective_auth, _callback), do: nil
284+
251285
defp throw_api_error(response) do
252286
code = response.status_code
253287
msg = "API returned status code #{code}"

src/main/resources/templates/elixir/test/api/pet_api_test.mustache

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,29 @@ defmodule {{moduleName}}.Api.PetApiTest do
501501
Agent.stop(name)
502502
end
503503

504+
# auth-struct-reaches-wire (regression guard for the Elixir auth-drop bug):
505+
# a REAL behaviour-struct authenticator (not a plain map) must have its
506+
# credential applied to the outbound request. The previous BaseApi read
507+
# `auth_headers` as a struct FIELD via `Map.has_key?`, which is always false
508+
# for a behaviour struct (the credential providers are callback FUNCTIONS),
509+
# so every configured authenticator was silently dropped. This pins the fix:
510+
# the credential is dispatched through the struct's module and reaches the
511+
# wire. A plain-map fixture (used by the tests above for override/fallback
512+
# logic) would NOT catch this bug, so this case uses the generated struct.
513+
test "configured BearerAuthenticator struct applies its credential to the wire" do
514+
{:ok, name} = HeaderCapturingApiClient.start()
515+
config = {{moduleName}}.Configuration.new(base_url: "http://localhost")
516+
bearer = {{moduleName}}.Auth.BearerAuthenticator.new("http://localhost", "real-struct-token")
517+
api = {{moduleName}}.Api.PetApi.new(HeaderCapturingApiClient, config, bearer)
518+
519+
{:ok, _result} = {{moduleName}}.Api.PetApi.delete_pet_with_http_info(api, 1)
520+
521+
headers = HeaderCapturingApiClient.captured_headers(name)
522+
assert headers["Authorization"] == "Bearer real-struct-token"
523+
524+
Agent.stop(name)
525+
end
526+
504527
# binary-request-body-fidelity: setPetAvatar declares a request body of
505528
# type:string format:binary with declared Content-Type image/jpeg. The raw
506529
# bytes must reach the wire UNCHANGED — never JSON-marshaled into an int-array

src/main/resources/templates/elixir/test/base_api_test.mustache

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -809,17 +809,28 @@ defmodule {{moduleName}}.Api.BaseApiTest do
809809
end
810810
end
811811

812+
# A real authenticator: the credential providers are behaviour CALLBACKS
813+
# (def auth_headers/1 ...), never struct data fields. base_api must dispatch
814+
# through the module to read them; a struct that merely *names* fields
815+
# :auth_headers/:query_params/:cookie_params would mask the dispatch bug.
812816
defmodule MapAuth do
813-
defstruct [:auth_headers, :query_params, :cookie_params]
817+
use {{moduleName}}.Auth.BaseAuthenticator
818+
819+
defstruct [:header_value]
820+
821+
@impl true
822+
def host(%__MODULE__{} = self), do: self.header_value
823+
824+
# The credential provider is a behaviour CALLBACK, not a struct field.
825+
# query_params/1 and cookie_params/1 fall back to the BaseAuthenticator
826+
# defaults (%{}), mirroring the real BearerAuthenticator.
827+
@impl true
828+
def auth_headers(%__MODULE__{} = self), do: %{"X-Auth" => self.header_value}
814829
end
815830

816831
test "client-level authenticator is used when no :auth opt is supplied" do
817832
{:ok, name} = AuthCapturingApiClient.start()
818-
client_auth = %MapAuth{
819-
auth_headers: %{"X-Auth" => "from-client"},
820-
query_params: %{},
821-
cookie_params: %{}
822-
}
833+
client_auth = %MapAuth{header_value: "from-client"}
823834
config = {{moduleName}}.Configuration.new(base_url: "http://localhost")
824835
state = %{config: config, api_client: AuthCapturingApiClient, authenticator: client_auth}
825836

@@ -833,16 +844,8 @@ defmodule {{moduleName}}.Api.BaseApiTest do
833844

834845
test "per-call :auth overrides client-level authenticator" do
835846
{:ok, name} = AuthCapturingApiClient.start()
836-
client_auth = %MapAuth{
837-
auth_headers: %{"X-Auth" => "from-client"},
838-
query_params: %{},
839-
cookie_params: %{}
840-
}
841-
per_call_auth = %MapAuth{
842-
auth_headers: %{"X-Auth" => "from-call"},
843-
query_params: %{},
844-
cookie_params: %{}
845-
}
847+
client_auth = %MapAuth{header_value: "from-client"}
848+
per_call_auth = %MapAuth{header_value: "from-call"}
846849
config = {{moduleName}}.Configuration.new(base_url: "http://localhost")
847850
state = %{config: config, api_client: AuthCapturingApiClient, authenticator: client_auth}
848851

@@ -854,6 +857,86 @@ defmodule {{moduleName}}.Api.BaseApiTest do
854857
Agent.stop(name)
855858
end
856859

860+
# AUTH-APPLIED (Wave A1): a configured authenticator must actually be applied
861+
# to a SECURED request. This is the regression guard for the Elixir bug where
862+
# base_api read auth_headers/query_params/cookie_params as struct DATA FIELDS
863+
# gated on Map.has_key?/2 — but those are behaviour CALLBACK FUNCTIONS on the
864+
# authenticator's module, so the guard was always false and the credential was
865+
# silently dropped. The test drives the real production path: a genuine
866+
# authenticator struct ({{moduleName}}.Auth.BearerAuthenticator /
867+
# {{moduleName}}.Auth.ApiKeyAuthenticator, whose providers are def callbacks,
868+
# NOT a struct that merely names :auth_headers fields) configured on the API
869+
# instance, issuing a secured operation (POST /pet), and asserting the
870+
# outbound request actually carried the credential.
871+
872+
defmodule AuthAppliedCapturingApiClient do
873+
@behaviour {{moduleName}}.ApiClient
874+
use Agent
875+
876+
def start do
877+
name = :"#{__MODULE__}-#{System.unique_integer([:positive])}"
878+
{:ok, _pid} = Agent.start_link(fn -> %{headers: %{}, url: ""} end, name: name)
879+
Process.put(__MODULE__, name)
880+
{:ok, name}
881+
end
882+
883+
def captured(name), do: Agent.get(name, & &1)
884+
885+
@impl true
886+
def send_request(_method, url, headers, _body) do
887+
name = Process.get(__MODULE__)
888+
Agent.update(name, fn _ -> %{headers: headers, url: url} end)
889+
{{! Empty body deserializes to nil for the "Pet" return type, so the }}
890+
{{! secured operation completes without tripping required-field decode. }}
891+
%{{moduleName}}.ApiHttpResponse{status_code: 200, body: "", headers: %{"Content-Type" => "application/json"}}
892+
end
893+
end
894+
895+
test "configured Bearer authenticator is applied to a secured operation" do
896+
{:ok, name} = AuthAppliedCapturingApiClient.start()
897+
config = {{moduleName}}.Configuration.new(base_url: "http://localhost")
898+
899+
authenticator =
900+
{{moduleName}}.Auth.BearerAuthenticator.new("http://localhost", "secret-token-123")
901+
902+
api = {{moduleName}}.Api.PetApi.new(AuthAppliedCapturingApiClient, config, authenticator)
903+
904+
pet = %{{moduleName}}.Models.Pet{name: "rex", photo_urls: []}
905+
_result = {{moduleName}}.Api.PetApi.add_pet(api, pet)
906+
907+
captured = AuthAppliedCapturingApiClient.captured(name)
908+
909+
assert captured.headers["Authorization"] == "Bearer secret-token-123",
910+
"Configured Bearer authenticator must add the Authorization header to the secured request, got: #{inspect(captured.headers)}"
911+
912+
Agent.stop(name)
913+
end
914+
915+
test "configured api-key authenticator is applied to a secured operation" do
916+
{:ok, name} = AuthAppliedCapturingApiClient.start()
917+
config = {{moduleName}}.Configuration.new(base_url: "http://localhost")
918+
919+
authenticator =
920+
{{moduleName}}.Auth.ApiKeyAuthenticator.new(
921+
"http://localhost",
922+
"X-API-Key",
923+
"key-value-456",
924+
:header
925+
)
926+
927+
api = {{moduleName}}.Api.PetApi.new(AuthAppliedCapturingApiClient, config, authenticator)
928+
929+
pet = %{{moduleName}}.Models.Pet{name: "rex", photo_urls: []}
930+
_result = {{moduleName}}.Api.PetApi.add_pet(api, pet)
931+
932+
captured = AuthAppliedCapturingApiClient.captured(name)
933+
934+
assert captured.headers["X-API-Key"] == "key-value-456",
935+
"Configured api-key authenticator must add the api-key header to the secured request, got: #{inspect(captured.headers)}"
936+
937+
Agent.stop(name)
938+
end
939+
857940
# Per-op server override (item #5)
858941

859942
defmodule UrlCapturingApiClient do

0 commit comments

Comments
 (0)