Skip to content

Commit 3dc1170

Browse files
committed
fix(auth): suppress the client authenticator on security:[] operations
An operation declared `security: []` is explicitly unauthenticated, but the generated api method passed nil/null as the auth argument, so base_api's `auth || client_authenticator` fallback re-acquired the client credential and attached it. That leaked the credential to endpoints the spec marks unauthenticated — including the testEcho* operations, which reflect the request back. Introduce a no-auth sentinel that security:[] operations pass instead of nil. base_api now resolves three states: the sentinel suppresses auth (no fallback), nil falls back to the client authenticator (unchanged), and an explicit authenticator is used as a per-call override (unchanged). Applied uniformly across all 12 SDKs with a security-none parity test and a secured-still-works guard. Existing tests that exercised auth through a security:[] op were repointed at a secured op.
1 parent 28927d5 commit 3dc1170

194 files changed

Lines changed: 5954 additions & 3137 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/java/io/github/mridang/codegen/generators/php/BetterPHPCodegen.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ protected List<SupportingFileSpec> getSupportingFileSpecs() {
283283
new SupportingFileSpec("servers.mustache", invokerFolder, "Servers.php"),
284284
new SupportingFileSpec("base_api.mustache", apiFolder, "BaseApi.php"),
285285
new SupportingFileSpec("authenticator.mustache", Path.of(SRC_BASE_PATH, "Auth").toString(), "Authenticator.php"),
286+
new SupportingFileSpec("auth/no_auth.mustache", Path.of(SRC_BASE_PATH, "Auth").toString(), "NoAuth.php"),
286287
new SupportingFileSpec("composer.mustache", "", "composer.json"),
287288
new SupportingFileSpec("phpstan_neon.mustache", "", "phpstan.neon"),
288289
new SupportingFileSpec("rector.mustache", "", "rector.php"),

src/main/resources/templates/csharp/api/api.mustache

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,13 @@ public class {{classname}} : BaseApi
449449
the last consume fires once only for two-or-more declared types. }}
450450
{{#consumes}}{{#-last}}{{^-first}} requestContentType ?? "{{vendorExtensions.op.effectiveConsumes}}",{{/-first}}{{#-first}} "{{vendorExtensions.op.effectiveConsumes}}",{{/-first}}{{/-last}}{{/consumes}}{{^consumes}} "{{vendorExtensions.op.effectiveConsumes}}",{{/consumes}}
451451
{{#returnType}}typeof({{{returnType}}}){{/returnType}}{{^returnType}}null{{/returnType}},
452-
{{#hasAuthMethods}}options?.Auth{{/hasAuthMethods}}{{^hasAuthMethods}}null{{/hasAuthMethods}}
452+
{{! Auth argument (Wave A2). A secured operation passes the optional per-call
453+
override (options?.Auth) — null there means "no override", so BaseApi falls
454+
back to the client-level authenticator. An operation with NO auth methods
455+
(security: []) is explicitly unauthenticated: pass the NoAuth sentinel so
456+
BaseApi suppresses the client-level authenticator and attaches no credential
457+
(passing null here would WRONGLY re-acquire it and leak the credential). }}
458+
{{#hasAuthMethods}}options?.Auth{{/hasAuthMethods}}{{^hasAuthMethods}}NoAuth.Instance{{/hasAuthMethods}}
453459
)
454460
.ConfigureAwait(false);
455461
}

src/main/resources/templates/csharp/authenticator.mustache

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,34 @@ public interface IAuthenticator
4848
return [];
4949
}
5050
}
51+
52+
/// <summary>
53+
/// Sentinel authenticator marking an operation as explicitly unauthenticated
54+
/// (an operation declared <c>security: []</c> in the spec).
55+
///
56+
/// This is one of the three auth states resolved by <c>BaseApi</c>: passing
57+
/// <see cref="NoAuth.Instance"/> as the per-call authenticator suppresses the
58+
/// client-level authenticator entirely, so no credential is attached. A
59+
/// <see langword="null"/> authenticator, by contrast, means "no per-call
60+
/// override" and falls back to the client-level authenticator; a real
61+
/// authenticator is used as a per-call override.
62+
///
63+
/// It is identity-compared (reference equality against
64+
/// <see cref="Instance"/>) inside the generated client and never returns any
65+
/// header, query parameter, or cookie, so even if it were ever applied it
66+
/// would contribute no credential. It is internal to the generated client;
67+
/// callers never construct or see it.
68+
/// </summary>
69+
internal sealed class NoAuth : IAuthenticator
70+
{
71+
/// <summary>The single shared no-auth sentinel instance.</summary>
72+
public static readonly NoAuth Instance = new();
73+
74+
private NoAuth() { }
75+
76+
/// <inheritdoc />
77+
public string GetHost() => "";
78+
79+
/// <inheritdoc />
80+
public Dictionary<string, string> GetAuthHeaders() => [];
81+
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,18 @@ public abstract class BaseApi
7979
{
8080
ArgumentNullException.ThrowIfNull(queryParams);
8181
ArgumentNullException.ThrowIfNull(headerParams);
82-
IAuthenticator? effectiveAuth = auth ?? Authenticator;
82+
/* Three-state auth resolution (the no-auth sentinel disambiguates the
83+
two meanings null used to carry):
84+
- auth is the NoAuth sentinel -> the operation is explicitly
85+
unauthenticated (security: []); apply NO credential and do NOT
86+
fall back to the client authenticator;
87+
- auth is null -> no per-call override on a secured
88+
operation; fall back to the client-level authenticator;
89+
- auth is any real authenticator -> per-call override; use it.
90+
Identity (reference) comparison against NoAuth.Instance is what
91+
distinguishes the sentinel from a real authenticator. */
92+
IAuthenticator? effectiveAuth =
93+
ReferenceEquals(auth, NoAuth.Instance) ? null : (auth ?? Authenticator);
8394
string url;
8495
if (path.StartsWith("http://", StringComparison.Ordinal) || path.StartsWith("https://", StringComparison.Ordinal))
8596
{

src/main/resources/templates/csharp/test/BaseApiTest.mustache

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,4 +920,92 @@ public class BaseApiTest
920920
null, ["application/json"], "application/json", perCallAuth);
921921
Assert.Equal("per-call-token", client.CapturedHeaders["X-Client-Auth"]);
922922
}
923+
924+
// ── Wave A2: client auth suppressed on security:[] operations ──
925+
926+
// SECURITY-NONE: an operation declared `security: []` is unauthenticated.
927+
// Even when the client is configured WITH an authenticator that would
928+
// attach an Authorization header, an api-key header, an api-key query
929+
// parameter, and an auth cookie, none of those may appear on the outbound
930+
// request for such an operation — otherwise the client credential leaks
931+
// (e.g. to a request-reflecting echo endpoint). getPetById is declared
932+
// `security: []` in the spec, so the api method passes the NoAuth sentinel
933+
// and BaseApi suppresses the client-level authenticator entirely.
934+
[Fact]
935+
public async Task SecurityNoneOperationSuppressesClientAuthenticator()
936+
{
937+
var client = new CapturingApiClient();
938+
var clientAuth = new TestAuthenticator(
939+
headers: new Dictionary<string, string>
940+
{
941+
{ "Authorization", "Bearer client-secret" },
942+
{ "api-key", "client-api-key" },
943+
},
944+
queryParams: new Dictionary<string, string> { { "api_key", "client-api-key" } },
945+
cookies: new Dictionary<string, string> { { "session", "client-cookie" } });
946+
var config = new Configuration("http://localhost");
947+
var api = new PetApi(client, config, clientAuth);
948+
949+
try
950+
{
951+
// getPetById is security:[]; the captured request is what matters.
952+
await api.GetPetByIdAsync(42L);
953+
}
954+
catch (Exception)
955+
{
956+
// The {} response body does not deserialize to a Pet; the request
957+
// is already captured by the time deserialization fails.
958+
}
959+
960+
Assert.False(client.CapturedHeaders.ContainsKey("Authorization"),
961+
"security:[] operation must NOT carry the client Authorization header");
962+
Assert.False(client.CapturedHeaders.ContainsKey("api-key"),
963+
"security:[] operation must NOT carry the client api-key header");
964+
Assert.False(client.CapturedHeaders.ContainsKey("Cookie"),
965+
"security:[] operation must NOT carry the client auth cookie");
966+
Assert.DoesNotContain("api_key=", client.CapturedUrl!.ToString());
967+
}
968+
969+
// SECURED (Wave A1 guard against over-suppression): a secured operation
970+
// with a configured client authenticator and NO per-call override must
971+
// STILL attach the credential. addPet is secured, so the api method passes
972+
// the per-call override (null here), and BaseApi falls back to the
973+
// client-level authenticator.
974+
[Fact]
975+
public async Task SecuredOperationStillAppliesClientAuthenticator()
976+
{
977+
var client = new CapturingApiClient();
978+
var clientAuth = new TestAuthenticator(
979+
headers: new Dictionary<string, string> { { "Authorization", "Bearer client-secret" } });
980+
var config = new Configuration("http://localhost");
981+
var api = new PetApi(client, config, clientAuth);
982+
983+
var pet = new PetstoreClient.Models.Pet { Name = "Rex", PhotoUrls = ["http://example/p.png"] };
984+
try
985+
{
986+
await api.AddPetAsync(pet);
987+
}
988+
catch (Exception)
989+
{
990+
// Captured request is asserted regardless of response shape.
991+
}
992+
993+
Assert.True(client.CapturedHeaders.ContainsKey("Authorization"),
994+
"secured operation with a client authenticator must still send the credential");
995+
Assert.Equal("Bearer client-secret", client.CapturedHeaders["Authorization"]);
996+
}
997+
998+
// The NoAuth sentinel is distinct from null and from any real authenticator,
999+
// is identity-comparable, and never contributes a credential.
1000+
[Fact]
1001+
public void NoAuthSentinelIsSingletonAndCarriesNoCredential()
1002+
{
1003+
Assert.Same(NoAuth.Instance, NoAuth.Instance);
1004+
// GetQueryParams/GetCookieParams are default interface methods, callable
1005+
// only through the IAuthenticator type, not the concrete NoAuth.
1006+
IAuthenticator noAuth = NoAuth.Instance;
1007+
Assert.Empty(noAuth.GetAuthHeaders());
1008+
Assert.Empty(noAuth.GetQueryParams());
1009+
Assert.Empty(noAuth.GetCookieParams());
1010+
}
9231011
}

src/main/resources/templates/dart/api/api.mustache

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,13 @@ class {{classname}} extends BaseApi {
443443
contentType: '{{vendorExtensions.op.effectiveConsumes}}',
444444
{{/vendorExtensions.op.hasMultipleConsumes}}
445445
returnType: '{{#returnType}}{{{.}}}{{/returnType}}',
446-
auth: {{#hasAuthMethods}}auth{{/hasAuthMethods}}{{^hasAuthMethods}}null{{/hasAuthMethods}},
446+
{{! security-none suppression: an operation without auth methods is }}
447+
{{! declared `security: []` and must NOT inherit the client-level }}
448+
{{! authenticator. Pass the `noAuth` sentinel (not `null`, which means }}
449+
{{! "fall back to the client authenticator") so base_api suppresses auth }}
450+
{{! entirely. A secured op passes its per-call override (`auth`, possibly }}
451+
{{! null for "use the client credential") unchanged. }}
452+
auth: {{#hasAuthMethods}}auth{{/hasAuthMethods}}{{^hasAuthMethods}}noAuth{{/hasAuthMethods}},
447453
{{#returnType}}
448454
{{#returnTypeIsPrimitive}}
449455
{{#isMap}}

src/main/resources/templates/dart/authenticator.mustache

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,41 @@ abstract class Authenticator {
2525
/// Returns cookie parameters to include for authentication.
2626
Map<String, String> cookieParams();
2727
}
28+
29+
/// Internal no-auth marker for operations declared `security: []` in the spec.
30+
///
31+
/// Such operations are explicitly unauthenticated: the client-level
32+
/// authenticator must NOT be applied to them. A plain `null` cannot express
33+
/// this because `null` already means "no per-call override — fall back to the
34+
/// client authenticator". This dedicated sentinel type makes the request path
35+
/// distinguish three states by identity:
36+
///
37+
/// - [noAuth] -> suppress auth entirely (security: [])
38+
/// - `null` -> fall back to the client authenticator
39+
/// - any other [Authenticator] -> per-call override
40+
///
41+
/// It is identity-comparable via the [noAuth] singleton and contributes no
42+
/// headers, query params, or cookies. Callers never construct or see it; the
43+
/// generated operation methods pass [noAuth] for unauthenticated operations.
44+
class NoAuthAuthenticator implements Authenticator {
45+
const NoAuthAuthenticator._();
46+
47+
@override
48+
String host() => '';
49+
50+
@override
51+
Map<String, String> authHeaders() => const {};
52+
53+
@override
54+
Future<Map<String, String>> authHeadersAsync() async => const {};
55+
56+
@override
57+
Map<String, String> queryParams() => const {};
58+
59+
@override
60+
Map<String, String> cookieParams() => const {};
61+
}
62+
63+
/// The single no-auth sentinel instance. Identity-compared in the request path
64+
/// to suppress authentication for `security: []` operations.
65+
const Authenticator noAuth = NoAuthAuthenticator._();

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,19 @@ class BaseApi {
6666
if (queryParams != null) {
6767
allQueryParams.addAll(queryParams);
6868
}
69-
final effectiveAuth = auth ?? _authenticator;
69+
/* Three-state auth resolution (security-none suppression):
70+
* - auth IS the `noAuth` sentinel -> the operation is declared
71+
* `security: []`; apply NO authentication. Do NOT fall back to the
72+
* client-level authenticator (that would re-acquire and leak the
73+
* client credential on an unauthenticated endpoint).
74+
* - auth is null (no per-call override) -> fall back to the
75+
* client-level `_authenticator` (a secured op uses the configured
76+
* credential). UNCHANGED Wave A1 behavior.
77+
* - auth is any other Authenticator -> use it as a per-call override.
78+
* The sentinel is compared by identity so it can never collide with a
79+
* real authenticator. */
80+
final Authenticator? effectiveAuth =
81+
identical(auth, noAuth) ? null : (auth ?? _authenticator);
7082
if (effectiveAuth != null) {
7183
allQueryParams.addAll(effectiveAuth.queryParams());
7284
}

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

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,10 @@ void main() {
238238
config: config,
239239
authenticator: auth);
240240

241-
await api.getPetById(1, null);
241+
// deletePet is a secured op, so the client-level authenticator's query
242+
// params ARE applied. (getPetById is security:[] and now correctly
243+
// suppresses the client credential — it can't be used to test forwarding.)
244+
await api.deletePet(1, null);
242245
expect(receivedQuery, contains('api_key=test123'));
243246
} finally {
244247
await server.close();
@@ -367,7 +370,8 @@ void main() {
367370
authenticator: clientAuth);
368371

369372
// No `auth:` passed → falls back to client-level authenticator.
370-
await api.getPetById(1, null);
373+
// deletePet is a secured op (security:[] ops no longer fall back).
374+
await api.deletePet(1, null);
371375
expect(receivedAuth, equals('Bearer client-level'));
372376
} finally {
373377
await server.close();
@@ -448,6 +452,71 @@ void main() {
448452
}
449453
});
450454

455+
/* security-none suppression (WAVE A2): an operation declared `security: []`
456+
* in the spec is explicitly unauthenticated. Even when the client is
457+
* configured WITH an authenticator, invoking such an operation must NOT
458+
* attach the client credential — no Authorization header, no api-key header
459+
* or query param, and no auth cookie may reach the wire. This guards
460+
* against the cross-cutting bug where a `security: []` op passed `null` as
461+
* its auth argument and base_api re-acquired the client authenticator,
462+
* leaking the credential (notably to the request-reflecting test ops).
463+
* getInventory is declared `security: []`, so it exercises the sentinel
464+
* path. The configured authenticator sets a header, a query param, AND a
465+
* cookie so all three credential channels are checked. */
466+
test('client authenticator is suppressed on a security:[] operation',
467+
() async {
468+
String? receivedAuth;
469+
String? receivedApiKeyHeader;
470+
String? receivedCookie;
471+
String? receivedQuery;
472+
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
473+
server.listen((request) {
474+
receivedAuth = request.headers.value('authorization');
475+
receivedApiKeyHeader = request.headers.value('api-key');
476+
receivedCookie = request.headers.value('cookie');
477+
receivedQuery = request.uri.queryParameters['api-key'];
478+
request.response
479+
..statusCode = 200
480+
..headers.contentType = ContentType.json
481+
..write('{"available":1}')
482+
..close();
483+
});
484+
485+
try {
486+
final config = ConfigurationBuilder()
487+
.baseUrl('http://localhost:${server.port}')
488+
.build();
489+
// Configure ALL three credential channels on the client authenticator.
490+
final clientAuth = _BaseApiAuth(
491+
headers: {
492+
'Authorization': 'Bearer client-secret',
493+
'api-key': 'header-key',
494+
},
495+
query: {'api-key': 'query-key'},
496+
cookies: {'session': 'cookie-value'},
497+
);
498+
final api = StoreApi(
499+
apiClient: DefaultApiClient(),
500+
config: config,
501+
authenticator: clientAuth);
502+
503+
// getInventory is declared `security: []` → fully unauthenticated.
504+
await api.getInventory();
505+
506+
expect(receivedAuth, isNull,
507+
reason:
508+
'no Authorization header may be sent on a security:[] op');
509+
expect(receivedApiKeyHeader, isNull,
510+
reason: 'no api-key header may be sent on a security:[] op');
511+
expect(receivedCookie, isNull,
512+
reason: 'no auth cookie may be sent on a security:[] op');
513+
expect(receivedQuery, isNull,
514+
reason: 'no api-key query param may be sent on a security:[] op');
515+
} finally {
516+
await server.close();
517+
}
518+
});
519+
451520
test('returns null data for empty 200 response', () async {
452521
final client = DefaultApiClient();
453522
final resp = await client.sendRequest(

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,13 @@ defmodule {{moduleName}}.Api.{{classname}} do
196196
auth = (options && Map.get(options, :auth)) || Map.get(api, :authenticator)
197197
{{/hasAuthMethods}}
198198
{{^hasAuthMethods}}
199-
# Operation declared `security: []` — no auth applied even if the
200-
# client has a default authenticator configured (OpenAPI 3.0 spec).
201-
auth = nil
199+
# Operation declared `security: []` — no auth applied even if the client
200+
# has a default authenticator configured (OpenAPI 3.0 spec). Pass the
201+
# BaseApi no-auth SENTINEL (not nil) so BaseApi suppresses the client
202+
# credential instead of falling back to it; nil would re-acquire the
203+
# client-level authenticator and leak the credential on this unauthenticated
204+
# operation.
205+
auth = {{moduleName}}.Api.BaseApi.no_auth()
202206
{{/hasAuthMethods}}
203207
{{#pathParams}}
204208
{{#required}}

0 commit comments

Comments
 (0)